diff --git a/.difyignore b/.difyignore new file mode 100644 index 0000000..265737f --- /dev/null +++ b/.difyignore @@ -0,0 +1,11 @@ +.git/ +.github/ +.env* +.venv +__pycache__/ +.pytest_cache/ +.ruff_cache/ +*.pyc +*.difypkg +tests/ +pyproject.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..68e458c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test provider +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements.txt pytest ruff + - run: python -m pytest tests -q + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: '1' + - run: ruff check . && ruff format --check . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..578c44d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env* +.venv +__pycache__/ +.pytest_cache/ +.ruff_cache/ +*.difypkg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64bb5cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ace Data Cloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..7cbd16a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,9 @@ +# Privacy policy — Fish Audio by Ace Data Cloud + +Effective date: 2026-10-07. Maintainer/contact: Ace Data Cloud, dev@acedata.cloud. + +This plugin sends your API key in an HTTPS Authorization header to `api.acedata.cloud`. It sends the inputs you choose for text-to-speech and public reference voice search, including prompts, text or lyrics, settings, reference media URLs where supported, and task IDs. Such inputs may contain personal information if you include it. The API also receives ordinary connection metadata, including the Dify server's IP address and usage required to operate the service. + +The plugin's only direct API recipient is Ace Data Cloud. API processing, retention and service providers follow the [Ace Data Cloud privacy policy](https://platform.acedata.cloud/privacy). Reference URLs are sent to the API; the plugin does not fetch arbitrary URLs or download media. It returns selected result fields and media links to Dify. It does not intentionally log keys, raw API errors, or saved request bodies, and has no database, telemetry or additional persistent storage. Dify handles credential storage, workflow history and downstream media rendering under your deployment's policies. API-side zero retention is not promised by this plugin. + +Only submit text and media you are authorized to use. Remove the credential from Dify or revoke it in the Ace Data Cloud console to stop use. Contact dev@acedata.cloud for API data questions and your Dify administrator for local history. This release does not train custom models, clone voices, execute generated code/tools, or perform payments or asset transfers. diff --git a/README.md b/README.md index f58edeb..c171647 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,61 @@ -# Fish Audio by Ace Data Cloud +# Fish Audio -Dify plugin source. Initial implementation is reviewed through the repository pull request. +**Author:** acedatacloud + +**Type:** tool provider plugin + +**API:** `https://api.acedata.cloud/fish/tts` + +## Tools + +| Tool ID | Purpose | Inputs | +|---|---|---| +| `fish_generate_audio` | Fish Generate Audio | `text`, `model`, `reference_id`, `format` | +| `fish_search_voices` | Fish Search Voices | `query`, `limit` | +| `fish_task_retrieve` | Fish Audio Retrieve Task | `task_id`, `wait_seconds` | + +Outputs include `status`, `task_id`, `media_urls`, `result`, and the earlier plugin conventions `success`, `trace_id`, `data`. `success` is true only when results are complete; failures raise tool errors. + +The credential field is `acedata_bearer_token`; paste the token without the `Bearer ` prefix. + +Synthesize Fish Audio speech and find reference voices with Ace Data Cloud. Maintained by Ace Data Cloud. This is a free Dify tool plugin; API use requires your own Ace Data Cloud account and may incur usage charges. + +## Setup + +1. Sign in at [Ace Data Cloud](https://platform.acedata.cloud/console/applications), activate the service and check [current pricing](https://platform.acedata.cloud/models). +2. Create an API key authorized for this service. A service key is scoped; use a global key only for services your account can access. +3. Install this plugin from Dify Marketplace **after it is published**. During review, source/package tests do not imply official listing. A development package can use Dify's documented remote-debug workflow in an isolated workspace. +4. In Dify **Integrations → Tools** (or **Plugins** in older versions), authorize the plugin with the key. Credential validation makes only a read-only task query; it never generates media. + +## Workflow + +1. Create **Start → Generate → Output** and select this plugin's generation tool. Fill the required prompt/text, model and options. Disable automatic retries for generation. +2. Save `task_id` from the result. Status `pending` means accepted, not finished. +3. Pass the same ID to **Retrieve or wait for task**. Set **Wait up to seconds** to 120–240 for a bounded wait, or 0 for one read. If it still returns `pending`, wait and query the same ID again. Do not resubmit generation. +4. When `status` is `succeeded`, read `media_urls` for the image/audio/video links. Use **Output** (or Chatflow **Answer**) to return the URLs. Task failures raise a Dify tool error, rather than returning a false success. + +Choose the speech engine in Model; the plugin sends it in the `model` HTTP header. Search voices returns IDs to use as reference_id. This release supports MP3/WAV and does not clone voices. + +This release covers text-to-speech and public reference voice search. Task queries return output fields only, excluding stored requests, account IDs and routing metadata. Generation uses asynchronous requests. Each HTTPS request has a 10-second connect and 60-second read timeout; a wait call polls for at most 240 seconds. No automatic paid retries or model fallback is implemented. On a submission timeout, check request history before attempting another submission. + +## Billing, network and privacy + +The plugin connects only to `api.acedata.cloud:443`; it does not download reference URLs or media itself. URLs are passed to the API as inputs or returned for downstream use. Only submit content you are authorized to process. + +Check [Usage](https://platform.acedata.cloud/console/usages) by key and time after a real call. Charges are in Credits; USD = Credits × your current package price / amount. Dify tool execution counts are not the billing ledger. A completed task must also have usable media. See [PRIVACY.md](PRIVACY.md). + +Python 3.12 and `dify-plugin==0.9.1` are required. Default models and listed options may change with the service; check the catalog before using a different model. Contact support for access errors; reduce concurrency on HTTP 429. Do not put keys into prompts or exported workflows. + +## Source and support + +- Source repository: https://github.com/AceDataCloud/FishAudioDify +- Contact: dev@acedata.cloud +- Issues: https://github.com/AceDataCloud/FishAudioDify/issues +- [Simplified Chinese](readme/README_zh_Hans.md) +- License: MIT + +Development: install `requirements.txt`, run `python -m pytest tests -q`, `ruff check .`, and `dify plugin package .`. Test results and limits are recorded in the submission PR; Marketplace publication is separate from local validation. + +## Earlier Dify plugin conventions + +This repository follows the earlier service-specific icon, tool naming, Bearer Token credential, bilingual metadata and code layout. The tool table above defines this release's scope; it does not restore all historical tools or the private fork's release workflows. diff --git a/_assets/icon.jpg b/_assets/icon.jpg new file mode 100644 index 0000000..d22d68d Binary files /dev/null and b/_assets/icon.jpg differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..312c326 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +from dify_plugin import DifyPluginEnv, Plugin + +plugin = Plugin(DifyPluginEnv(MAX_REQUEST_TIMEOUT=300)) + +if __name__ == "__main__": + plugin.run() diff --git a/manifest.yaml b/manifest.yaml new file mode 100644 index 0000000..febde2f --- /dev/null +++ b/manifest.yaml @@ -0,0 +1,41 @@ +version: 0.0.1 +type: plugin +author: acedatacloud +name: fish +label: + en_US: Fish Audio + ja_JP: Fish Audio + zh_Hans: Fish 语音 + pt_BR: Fish Audio +description: + en_US: Synthesize speech audio with the Ace Data Cloud Fish Audio API. + ja_JP: Ace Data Cloud Fish Audio API で音声を合成します。 + zh_Hans: 通过 Ace Data Cloud Fish Audio API 合成语音。 + pt_BR: Sintetize áudio de fala com a API Ace Data Cloud Fish Audio. +icon: icon.jpg +icon_dark: icon.jpg +resource: + memory: 268435456 + permission: + tool: + enabled: true +plugins: + tools: + - provider/fish.yaml +meta: + version: 0.0.1 + minimum_dify_version: 1.17.1 + arch: + - amd64 + - arm64 + runner: + language: python + version: '3.12' + entrypoint: main +created_at: '2026-10-07T12:00:00+08:00' +privacy: PRIVACY.md +repo: https://github.com/AceDataCloud/FishAudioDify +contact: dev@acedata.cloud +network: + domains: + - api.acedata.cloud diff --git a/provider/fish.py b/provider/fish.py new file mode 100644 index 0000000..2d2ebbb --- /dev/null +++ b/provider/fish.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from dify_plugin import ToolProvider +from dify_plugin.errors.tool import ToolProviderCredentialValidationError + +from tools.acedata_client import AceDataFishClient, AceDataFishError + + +class FishProvider(ToolProvider): + def _validate_credentials(self, credentials: dict[str, Any]) -> None: + token = credentials.get("acedata_bearer_token") + if not isinstance(token, str) or not token.strip(): + raise ToolProviderCredentialValidationError("Missing `acedata_bearer_token`.") + try: + AceDataFishClient(bearer_token=token).validate() + except (AceDataFishError, ValueError): + raise ToolProviderCredentialValidationError( + "Unable to validate the token. Check its service access and network connection." + ) from None diff --git a/provider/fish.yaml b/provider/fish.yaml new file mode 100644 index 0000000..cb87065 --- /dev/null +++ b/provider/fish.yaml @@ -0,0 +1,38 @@ +identity: + author: acedatacloud + name: fish + label: + en_US: Fish Audio + ja_JP: Fish Audio + zh_Hans: Fish 语音 + pt_BR: Fish Audio + description: + en_US: Synthesize speech audio with the Ace Data Cloud Fish Audio API. + ja_JP: Ace Data Cloud Fish Audio API で音声を合成します。 + zh_Hans: 通过 Ace Data Cloud Fish Audio API 合成语音。 + pt_BR: Sintetize áudio de fala com a API Ace Data Cloud Fish Audio. + icon: icon.jpg + icon_dark: icon.jpg + tags: + - entertainment +credentials_for_provider: + acedata_bearer_token: + type: secret-input + required: true + label: + en_US: Ace Data Cloud Bearer Token + zh_Hans: Ace Data Cloud Token + placeholder: + en_US: Paste your token (without "Bearer ") + zh_Hans: 粘贴 Token(不需要带 Bearer) + help: + en_US: Acquire a token with access to this service from the Ace Data Cloud console. + zh_Hans: 从 Ace Data Cloud 控制台获取具有此服务权限的 Token。 + url: https://platform.acedata.cloud/console/credentials +tools: +- tools/fish_generate.yaml +- tools/fish_search_voices.yaml +- tools/fish_task_retrieve.yaml +extra: + python: + source: provider/fish.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..435d8fe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/readme/README_zh_Hans.md b/readme/README_zh_Hans.md new file mode 100644 index 0000000..f6336b1 --- /dev/null +++ b/readme/README_zh_Hans.md @@ -0,0 +1,50 @@ +# Fish Audio + +**Author:** acedatacloud + +**Type:** tool provider plugin + +**API:** `https://api.acedata.cloud/fish/tts` + +## 工具 + +| 工具 ID | 功能 | 参数 | +|---|---|---| +| `fish_generate_audio` | Fish 生成音频 | `text`, `model`, `reference_id`, `format` | +| `fish_search_voices` | Fish 查询音色 | `query`, `limit` | +| `fish_task_retrieve` | Fish Audio 查询任务 | `task_id`, `wait_seconds` | + +输出包含 `status`、`task_id`、`media_urls`、`result`,以及旧插件约定的 `success`、`trace_id`、`data`。`success` 仅在结果完成时为 true;失败会抛出工具错误。 + +凭据字段为 `acedata_bearer_token`,粘贴 Token 时不需要 `Bearer ` 前缀。 + +通过 Ace Data Cloud 使用文本转语音与公开参考音色查询。插件免费安装,API 使用需要自己的账户与额度。 + +## 配置 + +1. 登录 [控制台](https://platform.acedata.cloud/console/applications),开通服务并检查[当前价格](https://platform.acedata.cloud/models)。创建有该服务权限的 API Key。 +2. 官方上架后,从 Dify Marketplace 安装;审核中的源码或插件包不等于官方已收录。开发验证可以在隔离工作区使用官方远程调试。 +3. 在 **Integrations → Tools(集成 → 工具)** 或旧版插件页面填写 Key。验证凭据只查询任务,不生成付费媒体。 +4. 工作流连接**开始 → 生成工具 → 输出**。填写提示词或文本、模型和选项,关闭生成步骤自动重试。 +5. 保存返回的 `task_id`,传给 **Retrieve or wait for task**。等待秒数为 0 时只查询一次,120–240 时进行有限等待。`pending` 表示未完成;继续查询同一 ID,不要重发生成。 +6. `status=succeeded` 后,通过 `media_urls` 获取媒体链接。用输出节点或 Chatflow 回答节点返回链接;任务失败会产生工具错误。 + +## 范围与计费 + +本版支持文本转语音与公开参考音色查询及任务查询。编辑图片时填写公开 HTTPS 图片地址;Seedance 本版只支持文本或首帧输入;Fish 本版不含声音克隆;Suno 的时长是目标值,中间预览不视作完成结果。 + +到[用量页](https://platform.acedata.cloud/console/usages)按 Key 和时间核对实际扣减。Credits 的 USD 换算使用当前套餐价格与额度。异步受理、任务成功、文件可读取及扣费应分别验证。 + +连接超时 10 秒,读取超时 60 秒,单次任务等待不超过 240 秒。网络不确定时先查请求历史,不盲目重发。本插件不自动重试付费请求或更换模型。 + +## 隐私与支持 + +Key、提示词、歌词/文本及所选参考媒体 URL 通过 HTTPS 发给 `api.acedata.cloud`。插件不下载任意 URL、不执行代码或额外持久化内容,Dify 管理凭据与运行历史。只使用有权处理的素材,详见 [隐私政策](../PRIVACY.md)。 + +源码:https://github.com/AceDataCloud/FishAudioDify + +支持:dev@acedata.cloud + +## 与旧 Dify 插件的对应 + +本仓库沿用旧插件的服务图标、工具命名、Bearer Token 配置、中英文说明和代码组织方式。运行范围以上方工具表为准;这是独立的官方市场投稿,不是旧 19 插件发布链的恢复。 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e9fc1b2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +dify-plugin==0.9.1 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..fea02c4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,17 @@ +# Live E2E validation + +`e2e-results.json` records real Dify CE workflows, not mocked API calls. +Use `e2e-cases.json` as the tool inputs for an isolated Dify 1.17.1 workspace. + +1. Enable the official remote-debug connection in your workspace and run `python -m main` with the workspace debug host/key. Keep signature verification enabled. +2. Authorize this provider with your service API key. Credential validation only queries task metadata. +3. Build Start → this plugin tool → Output. Put form parameters in the tool configuration and prompt/text parameters in tool inputs. Return status, task_id, media_urls and result. Disable generation retries. +4. Run each generation/edit case once, preserving its task ID. Poll via this plugin's task tool until succeeded. Do not regenerate to recover a timeout. +5. Check each output URL, decode the media, and match the task/trace and nonzero deduction in Ace Data Cloud Usage. +6. Remove the test credential and stop the isolated test environment. + +These cases consume real API usage. They are never part of CI. Results do not assert Dify Cloud, browser interaction, or Marketplace publication. + +## Service convention alignment + +`e2e-alignment.json` records the subsequent read-only verification of restored plugin/tool IDs, service icons, bilingual metadata, `acedata_bearer_token` authorization and actual task workflows. All nine earlier paid input cases map to identical API request payloads after the refactor. Existing task IDs were retrieved again; no new generation was submitted. Image tasks now also return native Dify image files, and `success`, `trace_id`, `data` follow the earlier tool conventions. diff --git a/tests/asset-and-convention-sources.json b/tests/asset-and-convention-sources.json new file mode 100644 index 0000000..38c3ed0 --- /dev/null +++ b/tests/asset-and-convention-sources.json @@ -0,0 +1,32 @@ +{ + "id": "fish", + "title": "Fish Audio", + "origin": { + "repository": "AceDataCloud/Dify", + "revision": "eaf0e68cd34cfe4cadece7a9ee34a98aac6b961e^", + "path": "plugins/fish/_assets/icon.jpg" + }, + "icons": { + "icon.jpg": "9403f99d21bb11dad69a6ed74707187abc5a4787e9d4484ad5312bb9da1ea1b6" + }, + "tools": [ + { + "operation": "generate", + "file": "fish_generate", + "id": "fish_generate_audio", + "class": "FishGenerateAudioTool" + }, + { + "operation": "voices", + "file": "fish_search_voices", + "id": "fish_search_voices", + "class": "FishSearchVoicesTool" + }, + { + "operation": "task", + "file": "fish_task_retrieve", + "id": "fish_task_retrieve", + "class": "FishTaskRetrieveTool" + } + ] +} diff --git a/tests/e2e-alignment.json b/tests/e2e-alignment.json new file mode 100644 index 0000000..189b3e7 --- /dev/null +++ b/tests/e2e-alignment.json @@ -0,0 +1,28 @@ +{ + "repo": "FishAudioDify", + "provider_id": "acedatacloud/fish/fish", + "icon_sha256": "9403f99d21bb11dad69a6ed74707187abc5a4787e9d4484ad5312bb9da1ea1b6", + "tool_ids": [ + "fish_generate_audio", + "fish_search_voices", + "fish_task_retrieve" + ], + "localized_labels": true, + "credential_validation": "passed", + "workflow_results": [ + { + "case": "fish-tts-result", + "task_id": "b24ec7c3-5007-4d10-94ec-10b0b48377d0", + "workflow_status": "succeeded", + "task_status": "succeeded", + "image_files": 0 + } + ], + "new_paid_generations": 0, + "environment": "Dify CE 1.17.1 remote-debug + console API workflows", + "browser_ui_tested": false, + "marketplace_install_tested": false, + "prior_paid_case_payloads_unchanged": true, + "legacy_data_contains_audio_url": true, + "voice_search": "passed" +} diff --git a/tests/e2e-cases.json b/tests/e2e-cases.json new file mode 100644 index 0000000..4aa6a10 --- /dev/null +++ b/tests/e2e-cases.json @@ -0,0 +1,19 @@ +[ + { + "label": "fish-tts", + "operation": "generate", + "parameters": { + "text": "Hello. This is the Ace Data Cloud Dify integration test.", + "model": "s2-pro", + "format": "mp3" + } + }, + { + "label": "fish-voices", + "operation": "voices", + "parameters": { + "query": "", + "limit": 3 + } + } +] diff --git a/tests/e2e-results.json b/tests/e2e-results.json new file mode 100644 index 0000000..fde2d4e --- /dev/null +++ b/tests/e2e-results.json @@ -0,0 +1,44 @@ +{ + "tested_at": "2026-10-07", + "environment": "Dify CE 1.17.1 / plugin daemon 0.6.10-local, official remote-debug, console API workflows", + "signature_verification": true, + "browser_ui_tested": false, + "marketplace_install_tested": false, + "cases": [ + { + "case": "fish-tts", + "operation": "generate", + "workflow_status": "succeeded", + "task_status": "pending", + "task_id": "b24ec7c3-5007-4d10-94ec-10b0b48377d0", + "media_urls": [] + }, + { + "case": "fish-tts-result", + "operation": "task", + "workflow_status": "succeeded", + "task_status": "succeeded", + "task_id": "b24ec7c3-5007-4d10-94ec-10b0b48377d0", + "media_urls": [ + "https://platform2.cdn.acedata.cloud/fish/b24ec7c3-5007-4d10-94ec-10b0b48377d0.mp3" + ] + }, + { + "case": "fish-voices", + "operation": "voices", + "workflow_status": "succeeded", + "task_status": "succeeded", + "task_id": "", + "media_urls": [] + } + ], + "billing": [ + { + "case": "fish-tts", + "task_id": "b24ec7c3-5007-4d10-94ec-10b0b48377d0", + "matched_records": 1, + "http_status": 200, + "deducted_credits": 0.005558111999999999 + } + ] +} diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..a7a2f32 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,189 @@ +import json +from pathlib import Path + +import dify_plugin # noqa: F401 +import pytest +import requests +from dify_plugin.config.config import DifyPluginEnv +from dify_plugin.core.plugin_registration import PluginRegistration + +from tools.acedata_client import BASE, SERVICE, https_urls +from tools.acedata_client import AceDataFishClient as Client +from tools.acedata_client import AceDataFishError as APIError + + +def response(body, status=200): + r = requests.Response() + r.status_code = status + r._content = json.dumps(body).encode() + r.close = lambda: None + return r + + +def test_plugin_loads_all_declared_tools(): + registration = PluginRegistration(DifyPluginEnv()) + assert registration.configuration.name + assert Path("_assets/" + registration.configuration.icon).is_file() + + +def test_credential_validation_is_read_only(monkeypatch): + calls = [] + + def request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return response({}) + + monkeypatch.setattr(requests, "request", request) + Client("sample").validate() + method, url, kwargs = calls[0] + assert method == "POST" and url == BASE + SERVICE["prefix"] + "/tasks" + assert kwargs["json"]["action"] == "retrieve" + assert kwargs["allow_redirects"] is False + + +@pytest.mark.parametrize("status", [400, 401, 403, 429, 500]) +def test_api_errors_redact_bodies(monkeypatch, status): + monkeypatch.setattr( + requests, "request", lambda *a, **k: response({"error": "private-user-data"}, status) + ) + with pytest.raises(APIError) as exc: + Client("secret").validate() + assert str(status) in str(exc.value) + assert "private-user-data" not in str(exc.value) + assert "secret" not in str(exc.value) + + +def test_network_failure_does_not_retry_submission(monkeypatch): + calls = [] + + def fail(*a, **kw): + calls.append(1) + raise requests.Timeout("private-user-data") + + monkeypatch.setattr(requests, "request", fail) + with pytest.raises(APIError) as exc: + Client("sample").request("POST", SERVICE["prefix"] + "/tasks", {}) + assert len(calls) == 1 and "private-user-data" not in str(exc.value) + + +def test_task_strips_request_and_identity(monkeypatch): + key = {"image": "image_url", "video": "video_url", "audio": "audio_url"}[SERVICE["media"]] + body = { + "id": "task", + "finished_at": 10, + "user_id": "private", + "request": {"prompt": "private"}, + "response": { + "success": True, + "state": "complete", + "data": [{key: "https://cdn.example.org/result", "internal": "private"}], + }, + } + monkeypatch.setattr(requests, "request", lambda *a, **k: response(body)) + result = Client("sample").execute("task", {"task_id": "task", "wait_seconds": 0}) + assert result["status"] == "succeeded" + assert result["media_urls"] == ["https://cdn.example.org/result"] + assert "private" not in json.dumps(result) + + +def test_pending_media_is_not_reported_complete(monkeypatch): + key = {"image": "image_url", "video": "video_url", "audio": "audio_url"}[SERVICE["media"]] + body = { + "id": "task", + "response": { + "success": True, + "state": "pending", + "data": [{key: "https://cdn.example.org/preview"}], + }, + } + monkeypatch.setattr(requests, "request", lambda *a, **k: response(body)) + result = Client("sample").execute("task", {"task_id": "task", "wait_seconds": 0}) + assert result["status"] == "pending" and result["media_urls"] == [] + + +def test_failed_task_is_not_reported_as_success(monkeypatch): + monkeypatch.setattr( + requests, + "request", + lambda *a, **k: response({"response": {"success": False, "error": "private"}}), + ) + with pytest.raises(APIError): + Client("sample").execute("task", {"task_id": "task", "wait_seconds": 0}) + + +def test_input_validation_before_network(monkeypatch): + monkeypatch.setattr( + requests, "request", lambda *a, **k: pytest.fail("invalid input made a request") + ) + with pytest.raises(ValueError): + Client("sample").execute("generate", {}) + with pytest.raises(ValueError): + https_urls(["http://example.org/private"]) + + +def test_service_specific_payload(): + p = {"prompt": "test", "text": "test", "model": SERVICE["default"]} + data, path, header = Client("sample").payload("generate", p) + assert data["async"] is True + assert path.startswith(SERVICE["prefix"] + "/") + if SERVICE["family"] == "fish": + assert header == SERVICE["default"] and "model" not in data + else: + assert data["model"] == SERVICE["default"] + if SERVICE["family"] == "seedance": + assert data["content"] == [{"type": "text", "text": "test"}] + if SERVICE["family"] in {"nano", "gpt"}: + with pytest.raises(ValueError): + Client("sample").payload("edit", p) + data, _, _ = Client("sample").payload( + "edit", {**p, "image_urls": "https://example.org/input.png"} + ) + assert data.get("image_urls", data.get("image")) == ["https://example.org/input.png"] + + +@pytest.mark.parametrize("complete", [False, True]) +def test_task_tool_uses_legacy_credentials_and_output_messages(monkeypatch, complete): + from dify_plugin.entities.tool import ToolRuntime + + from tools.fish_task_retrieve import FishTaskRetrieveTool + + key = {"image": "image_url", "video": "video_url", "audio": "audio_url"}[SERVICE["media"]] + url = "https://cdn.example.org/output" + body = { + "id": "existing-task", + "finished_at": 10 if complete else None, + "response": { + "success": True, + "state": "complete" if complete else "pending", + "trace_id": "test-trace", + key: url, + }, + } + calls = [] + + def request(method, endpoint, **kwargs): + calls.append((method, endpoint, kwargs)) + return response(body) + + monkeypatch.setattr(requests, "request", request) + tool = FishTaskRetrieveTool( + runtime=ToolRuntime( + credentials={"acedata_bearer_token": "owned-test-token"}, user_id=None, session_id=None + ), + session=None, + ) + messages = list(tool._invoke({"task_id": "existing-task", "wait_seconds": 0})) + variables = { + m.message.variable_name: m.message.variable_value + for m in messages + if m.type.value == "variable" + } + assert len(calls) == 1 + assert calls[0][2]["headers"]["Authorization"] == "Bearer owned-test-token" + assert calls[0][2]["json"] == {"action": "retrieve", "id": "existing-task"} + assert variables["success"] is complete + assert variables["status"] == ("succeeded" if complete else "pending") + assert variables["trace_id"] == "test-trace" + assert variables["data"] == body["response"] + media_type = "image" if SERVICE["media"] == "image" else "link" + assert len([m for m in messages if m.type.value == media_type]) == int(complete) diff --git a/tools/acedata_client.py b/tools/acedata_client.py new file mode 100644 index 0000000..ec73d30 --- /dev/null +++ b/tools/acedata_client.py @@ -0,0 +1,288 @@ +"""Fixed Ace Data Cloud APIs; submission is never retried automatically.""" + +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import urlsplit + +import requests + +SERVICE = { + "family": "fish", + "prefix": "/fish", + "media": "audio", + "default": "s2-pro", + "models": ["s1", "s2-pro", "s2.1-pro"], +} +BASE = "https://api.acedata.cloud" +TERMINAL_FAILURES = {"failed", "error", "cancelled", "canceled", "rejected"} +TERMINAL_SUCCESS = {"complete", "completed", "succeeded", "succeed", "success", "finished"} + + +class AceDataFishError(RuntimeError): + pass + + +def integer(value: Any, field: str, low: int, high: int) -> int: + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer.") + try: + number = int(value) + if float(value) != number or not low <= number <= high: + raise ValueError + return number + except (ValueError, TypeError, OverflowError): + raise ValueError(f"{field} must be an integer from {low} to {high}.") from None + + +def https_urls(value: Any, required: bool = False) -> list[str]: + if not value: + if required: + raise ValueError("At least one reference image URL is required.") + return [] + if isinstance(value, str): + value = json.loads(value) if value.strip().startswith("[") else value.splitlines() + if not isinstance(value, list) or not 1 <= len(value) <= 16: + raise ValueError("Provide 1–16 public HTTPS image URLs.") + result = [] + for url in value: + if not isinstance(url, str): + raise ValueError("Each reference must be a public HTTPS URL.") + url = url.strip() + parsed = urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("Each reference must be a public HTTPS URL without user credentials.") + result.append(url) + return result + + +def required_text(params: dict[str, Any], name: str) -> str: + value = params.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} is required.") + return value.strip() + + +def choice(params: dict[str, Any], name: str, allowed: list[str], default: str) -> str: + value = params.get(name) or default + if value not in allowed: + raise ValueError(f"Unsupported {name} value.") + return value + + +def safe_result(value: Any) -> Any: + """Return output fields, excluding saved request, identity and routing metadata.""" + fields = { + "data", + "content", + "success", + "id", + "task_id", + "trace_id", + "state", + "status", + "model", + "image_url", + "audio_url", + "video_url", + "last_frame_url", + "url", + "title", + "lyric", + "duration", + "width", + "height", + "created", + "format", + "items", + "_id", + "languages", + "tags", + "prompt_tokens", + "completion_tokens", + "total_tokens", + "usage", + } + if isinstance(value, dict): + return {k: safe_result(v) for k, v in value.items() if k in fields} + if isinstance(value, list): + return [safe_result(v) for v in value] + return value + + +def media_urls(value: Any) -> list[str]: + urls = [] + keys = {"image": {"url", "image_url"}, "audio": {"audio_url"}, "video": {"video_url"}}[ + SERVICE["media"] + ] + + def walk(node: Any) -> None: + if isinstance(node, dict): + for k, v in node.items(): + if k in keys and isinstance(v, str) and v.startswith("https://"): + urls.append(v) + elif k in {"data", "content"}: + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + + walk(value) + return list(dict.fromkeys(urls)) + + +class AceDataFishClient: + def __init__(self, bearer_token: str) -> None: + key = bearer_token + if not isinstance(key, str) or not key.strip(): + raise ValueError("An Ace Data Cloud API key is required.") + self.key = key.strip().removeprefix("Bearer ").strip() + if not self.key: + raise ValueError("An Ace Data Cloud API key is required.") + + def request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + model_header: str | None = None, + ) -> dict[str, Any]: + headers = {"Authorization": f"Bearer {self.key}", "Accept": "application/json"} + if model_header: + headers["model"] = model_header + try: + with requests.request( + method, + BASE + path, + headers=headers, + json=payload, + params=query, + timeout=(10, 60), + allow_redirects=False, + ) as response: + if response.status_code != 200: + hints = { + 400: "Check inputs and model access.", + 401: "Check the API key.", + 403: "Check permissions and content restrictions.", + 429: "Rate limited; wait before trying again.", + } + raise AceDataFishError( + f"Ace Data Cloud HTTP {response.status_code}. " + + hints.get( + response.status_code, + "Service unavailable. Check request history before resubmitting.", + ) + ) + body = response.json() + except requests.RequestException: + raise AceDataFishError( + "Connection failed. Do not resubmit blindly; check request history for an accepted task." + ) from None + except ValueError: + raise AceDataFishError("The API returned an invalid JSON response.") from None + if not isinstance(body, dict): + raise AceDataFishError("The API returned an invalid result.") + if body.get("success") is False or body.get("error"): + raise AceDataFishError( + "The API reported a failure. Check the request in the Ace Data Cloud console." + ) + return body + + def validate(self) -> None: + self.request( + "POST", + SERVICE["prefix"] + "/tasks", + {"action": "retrieve", "id": "00000000-0000-0000-0000-000000000000"}, + ) + + def execute(self, action: str, params: dict[str, Any]) -> dict[str, Any]: + if action == "task": + task_id = required_text(params, "task_id") + wait = integer(params.get("wait_seconds", 0), "wait_seconds", 0, 240) + deadline = time.monotonic() + wait + while True: + body = self.request( + "POST", SERVICE["prefix"] + "/tasks", {"action": "retrieve", "id": task_id} + ) + result = self.normalise(body, task_id, retrieved=True) + if result["status"] != "pending" or time.monotonic() >= deadline: + return result + time.sleep(min(5, max(0, deadline - time.monotonic()))) + if action == "voices": + query = { + "page_number": 1, + "page_size": integer(params.get("limit", 10), "limit", 1, 50), + } + if params.get("query"): + query["title"] = params["query"] + body = self.request("GET", "/fish/model", query=query) + return { + "status": "succeeded", + "task_id": "", + "media_urls": [], + "result": safe_result(body), + } + if action not in {"generate", "edit"}: + raise ValueError("Unsupported operation.") + payload, path, model_header = self.payload(action, params) + body = self.request("POST", path, payload, model_header=model_header) + return self.normalise(body, str(body.get("task_id") or body.get("id") or "")) + + def payload(self, action: str, p: dict[str, Any]) -> tuple[dict[str, Any], str, str | None]: + model = choice(p, "model", SERVICE["models"], SERVICE["default"]) + data = {"model": model, "async": True} + header = None + data.pop("model") + header = model + data.update( + text=required_text(p, "text"), format=choice(p, "format", ["mp3", "wav"], "mp3") + ) + if p.get("reference_id"): + data["reference_id"] = p["reference_id"] + path = "/fish/tts" + return (data, path, header) + + def normalise( + self, body: dict[str, Any], task_id: str, retrieved: bool = False + ) -> dict[str, Any]: + if retrieved and (not body): + return {"status": "pending", "task_id": task_id, "media_urls": [], "result": {}} + result = body.get("response") if retrieved else body + if not isinstance(result, dict): + return {"status": "pending", "task_id": task_id, "media_urls": [], "result": {}} + data = result.get("data") + states = [str(result[k]).lower() for k in ["state", "status"] if result.get(k)] + if isinstance(data, dict): + states += [str(data[k]).lower() for k in ["state", "status"] if data.get(k)] + if isinstance(data, list): + states += [ + str(item[k]).lower() + for item in data + if isinstance(item, dict) + for k in ["state", "status"] + if item.get(k) + ] + if ( + result.get("success") is False + or result.get("error") + or any((s in TERMINAL_FAILURES for s in states)) + ): + raise AceDataFishError( + f"Generation failed for task {task_id}. Check the console for details." + ) + urls = media_urls(result) + done = bool(urls) and (not states or all((s in TERMINAL_SUCCESS for s in states))) + if not task_id and (not done): + raise AceDataFishError( + "No task ID or completed media returned; check request history before resubmitting." + ) + return { + "status": "succeeded" if done else "pending", + "task_id": task_id, + "media_urls": urls if done else [], + "result": safe_result(result), + } diff --git a/tools/fish_generate.py b/tools/fish_generate.py new file mode 100644 index 0000000..954e116 --- /dev/null +++ b/tools/fish_generate.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.acedata_client import AceDataFishClient + + +class FishGenerateAudioTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + client = AceDataFishClient( + bearer_token=self.runtime.credentials.get("acedata_bearer_token", "") + ) + result = client.execute("generate", tool_parameters) + yield self.create_json_message(result) + for field in ["status", "task_id", "media_urls", "result"]: + yield self.create_variable_message(field, result[field]) + yield self.create_variable_message("success", result["status"] == "succeeded") + yield self.create_variable_message("trace_id", result["result"].get("trace_id") or "") + yield self.create_variable_message("data", result["result"]) + for url in result["media_urls"]: + yield self.create_link_message(url) diff --git a/tools/fish_generate.yaml b/tools/fish_generate.yaml new file mode 100644 index 0000000..96d312d --- /dev/null +++ b/tools/fish_generate.yaml @@ -0,0 +1,106 @@ +identity: + name: fish_generate_audio + author: acedatacloud + label: + en_US: Fish Generate Audio + zh_Hans: Fish 生成音频 +description: + human: + en_US: Generate text-to-speech audio with the Ace Data Cloud Fish Audio API. + zh_Hans: 通过 Ace Data Cloud Fish Audio API 合成语音。 + llm: Generate text-to-speech audio via the Ace Data Cloud Fish Audio API. Returns + a task ID; use the task retrieval tool to obtain completed media. Never repeat + a pending submission. +parameters: +- name: text + label: + en_US: Text + zh_Hans: 文本 + type: string + required: true + form: llm + human_description: + en_US: Text to synthesize as speech. + zh_Hans: 需要合成语音的文本。 + llm_description: Text to synthesize as speech. +- name: model + label: + en_US: Model + zh_Hans: 模型 + type: select + required: false + form: form + human_description: + en_US: Select the model. Available models, access and pricing are shown in the + Ace Data Cloud console. + zh_Hans: 选择要调用的模型。模型权限和价格以 Ace Data Cloud 控制台为准。 + llm_description: Select the model. Available models, access and pricing are shown + in the Ace Data Cloud console. + default: s2-pro + options: + - value: s1 + label: + en_US: s1 + zh_Hans: s1 + - value: s2-pro + label: + en_US: s2-pro + zh_Hans: s2-pro + - value: s2.1-pro + label: + en_US: s2.1-pro + zh_Hans: s2.1-pro +- name: reference_id + label: + en_US: Voice ID + zh_Hans: 音色 ID + type: string + required: false + form: llm + human_description: + en_US: Optional public voice ID from Search voices; omit to use the service default. + zh_Hans: 可选的公开音色 ID,可通过查询音色工具获取;留空使用服务默认音色。 + llm_description: Optional public voice ID from Search voices; omit to use the service + default. +- name: format + label: + en_US: Format + zh_Hans: 输出格式 + type: select + required: false + form: form + human_description: + en_US: Output audio file format. + zh_Hans: 生成音频的文件格式。 + llm_description: Output audio file format. + default: mp3 + options: + - value: mp3 + label: + en_US: mp3 + zh_Hans: mp3 + - value: wav + label: + en_US: wav + zh_Hans: wav +output_schema: + type: object + properties: + status: + type: string + task_id: + type: string + media_urls: + type: array + items: + type: string + result: + type: object + success: + type: boolean + trace_id: + type: string + data: {} +extra: + python: + source: tools/fish_generate.py diff --git a/tools/fish_search_voices.py b/tools/fish_search_voices.py new file mode 100644 index 0000000..d7bda60 --- /dev/null +++ b/tools/fish_search_voices.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.acedata_client import AceDataFishClient + + +class FishSearchVoicesTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + client = AceDataFishClient( + bearer_token=self.runtime.credentials.get("acedata_bearer_token", "") + ) + result = client.execute("voices", tool_parameters) + yield self.create_json_message(result) + for field in ["status", "task_id", "media_urls", "result"]: + yield self.create_variable_message(field, result[field]) + yield self.create_variable_message("success", result["status"] == "succeeded") + yield self.create_variable_message("trace_id", result["result"].get("trace_id") or "") + yield self.create_variable_message("data", result["result"]) + for url in result["media_urls"]: + yield self.create_link_message(url) diff --git a/tools/fish_search_voices.yaml b/tools/fish_search_voices.yaml new file mode 100644 index 0000000..035ede1 --- /dev/null +++ b/tools/fish_search_voices.yaml @@ -0,0 +1,56 @@ +identity: + name: fish_search_voices + author: acedatacloud + label: + en_US: Fish Search Voices + zh_Hans: Fish 查询音色 +description: + human: + en_US: Fish Search Voices via Ace Data Cloud. + zh_Hans: 通过 Ace Data Cloud Fish 查询音色。 + llm: Fish Search Voices via Ace Data Cloud. +parameters: +- name: query + label: + en_US: Voice title search + zh_Hans: 音色名称 + type: string + required: false + form: llm + human_description: + en_US: Search public voices by title; leave empty to list public voices. + zh_Hans: 按标题搜索公开音色;留空返回公开音色列表。 + llm_description: Search public voices by title; leave empty to list public voices. +- name: limit + label: + en_US: Maximum voices + zh_Hans: 最多返回音色数 + type: number + required: false + form: form + human_description: + en_US: Return between 1 and 50 public voices. + zh_Hans: 返回 1–50 个公开音色。 + llm_description: Return between 1 and 50 public voices. + default: 10 +output_schema: + type: object + properties: + status: + type: string + task_id: + type: string + media_urls: + type: array + items: + type: string + result: + type: object + success: + type: boolean + trace_id: + type: string + data: {} +extra: + python: + source: tools/fish_search_voices.py diff --git a/tools/fish_task_retrieve.py b/tools/fish_task_retrieve.py new file mode 100644 index 0000000..3e91720 --- /dev/null +++ b/tools/fish_task_retrieve.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.acedata_client import AceDataFishClient + + +class FishTaskRetrieveTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage, None, None]: + client = AceDataFishClient( + bearer_token=self.runtime.credentials.get("acedata_bearer_token", "") + ) + result = client.execute("task", tool_parameters) + yield self.create_json_message(result) + for field in ["status", "task_id", "media_urls", "result"]: + yield self.create_variable_message(field, result[field]) + yield self.create_variable_message("success", result["status"] == "succeeded") + yield self.create_variable_message("trace_id", result["result"].get("trace_id") or "") + yield self.create_variable_message("data", result["result"]) + for url in result["media_urls"]: + yield self.create_link_message(url) diff --git a/tools/fish_task_retrieve.yaml b/tools/fish_task_retrieve.yaml new file mode 100644 index 0000000..8369e53 --- /dev/null +++ b/tools/fish_task_retrieve.yaml @@ -0,0 +1,59 @@ +identity: + name: fish_task_retrieve + author: acedatacloud + label: + en_US: Fish Audio Retrieve Task + zh_Hans: Fish Audio 查询任务 +description: + human: + en_US: Retrieve a task or wait up to 240 seconds for completion. + zh_Hans: 查询任务,或最多等待 240 秒至完成。 + llm: Retrieve an existing task. Poll the same task ID if pending; never resubmit + generation to check progress. +parameters: +- name: task_id + label: + en_US: Task ID + zh_Hans: 任务 ID + type: string + required: true + form: llm + human_description: + en_US: The task ID returned by a generation or edit tool. + zh_Hans: 生成或编辑工具返回的任务 ID。 + llm_description: The task ID returned by a generation or edit tool. +- name: wait_seconds + label: + en_US: Wait up to seconds + zh_Hans: 最长等待秒数 + type: number + required: false + form: form + human_description: + en_US: 0 returns current status; 1–240 polls until completion. Pending results + keep the same task ID. Do not resubmit generation. + zh_Hans: 0 表示仅查询一次;1–240 表示等待至完成或超时。未完成时继续查询同一任务,请勿重复提交生成。 + llm_description: 0 returns current status; 1–240 polls until completion. Pending + results keep the same task ID. Do not resubmit generation. + default: 0 +output_schema: + type: object + properties: + status: + type: string + task_id: + type: string + media_urls: + type: array + items: + type: string + result: + type: object + success: + type: boolean + trace_id: + type: string + data: {} +extra: + python: + source: tools/fish_task_retrieve.py