@petercjl/seedaudiocli 0.0.0-bootstrap.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .config import seedaudio_api_key, seedaudio_base_url
12
+
13
+
14
+ class SeedAudioError(RuntimeError):
15
+ def __init__(self, message: str, code: str = "PROVIDER_FAILURE"):
16
+ super().__init__(message)
17
+ self.code = code
18
+
19
+
20
+ def _headers(json_body: bool = True) -> dict[str, str]:
21
+ key = seedaudio_api_key()
22
+ if not key:
23
+ raise SeedAudioError("Run seedaudiocli setup to import a configuration file.", "CONFIG_REQUIRED")
24
+ headers = {"Authorization": f"Bearer {key}"}
25
+ if json_body:
26
+ headers["Content-Type"] = "application/json"
27
+ return headers
28
+
29
+
30
+ def api_root() -> str:
31
+ return seedaudio_base_url()
32
+
33
+
34
+ def create_url() -> str:
35
+ return f"{api_root()}/v1/audios/generations"
36
+
37
+
38
+ def task_url(task_id: str) -> str:
39
+ return f"{api_root()}/v1/tasks/{task_id}"
40
+
41
+
42
+ def _request(method: str, url: str, body: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
43
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
44
+ req = urllib.request.Request(url, data=data, headers=_headers(json_body=body is not None), method=method)
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ text = resp.read().decode("utf-8")
48
+ if not text:
49
+ return {}
50
+ return json.loads(text)
51
+ except urllib.error.HTTPError as e:
52
+ text = e.read().decode("utf-8", errors="replace")
53
+ if e.code in {401, 403}:
54
+ raise SeedAudioError("Provider rejected authorization. Check key validity and account permissions.", "AUTH_REQUIRED") from e
55
+ raise SeedAudioError(f"HTTP {e.code}: {text}") from e
56
+ except urllib.error.URLError as e:
57
+ raise SeedAudioError(f"Network error: {e}") from e
58
+ except json.JSONDecodeError as e:
59
+ raise SeedAudioError(f"Non-JSON response from EvoLink: {e}") from e
60
+
61
+
62
+ def _request_with_retry(method: str, url: str, body: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
63
+ last_error: SeedAudioError | None = None
64
+ for attempt in range(1, 4):
65
+ try:
66
+ return _request(method, url, body, timeout)
67
+ except SeedAudioError as e:
68
+ last_error = e
69
+ if attempt < 3:
70
+ time.sleep(min(2 * attempt, 8))
71
+ raise last_error or SeedAudioError("Request failed")
72
+
73
+
74
+ def create_task(payload: dict[str, Any]) -> dict[str, Any]:
75
+ return _request("POST", create_url(), payload)
76
+
77
+
78
+ def get_task(task_id: str) -> dict[str, Any]:
79
+ return _request_with_retry("GET", task_url(task_id), None, timeout=60)
80
+
81
+
82
+ def extract_task_id(response: dict[str, Any]) -> str | None:
83
+ for key in ("id", "task_id", "taskId"):
84
+ value = response.get(key)
85
+ if value:
86
+ return str(value)
87
+ return None
88
+
89
+
90
+ def extract_status(response: dict[str, Any]) -> str:
91
+ value = response.get("status")
92
+ return str(value).lower() if value else "unknown"
93
+
94
+
95
+ def extract_audio_urls(response: dict[str, Any]) -> list[str]:
96
+ urls: list[str] = []
97
+ result_data = response.get("result_data")
98
+ if isinstance(result_data, list):
99
+ for item in result_data:
100
+ if isinstance(item, dict):
101
+ value = item.get("audio_url")
102
+ if isinstance(value, str) and value.startswith(("http://", "https://")):
103
+ urls.append(value)
104
+ results = response.get("results")
105
+ if isinstance(results, list):
106
+ for value in results:
107
+ if isinstance(value, str) and value.startswith(("http://", "https://")):
108
+ urls.append(value)
109
+ seen: list[str] = []
110
+ for url in urls:
111
+ if url not in seen:
112
+ seen.append(url)
113
+ return seen
114
+
115
+
116
+ def infer_extension(url: str, output_format: str | None = None) -> str:
117
+ match = re.search(r"\.(mp3|wav|ogg|opus|pcm)(\?|$)", url, re.I)
118
+ if match:
119
+ ext = match.group(1).lower()
120
+ return "ogg_opus" if ext == "opus" else ext
121
+ return output_format or "mp3"
122
+
123
+
124
+ def wait_for_task(task_id: str, interval: int = 5, timeout: int = 1800) -> dict[str, Any]:
125
+ deadline = time.time() + timeout
126
+ last_response: dict[str, Any] = {}
127
+ while time.time() < deadline:
128
+ last_response = get_task(task_id)
129
+ status = extract_status(last_response)
130
+ if status in {"completed", "failed", "cancelled", "canceled", "expired"}:
131
+ return last_response
132
+ time.sleep(interval)
133
+ raise SeedAudioError(f"Timed out waiting for task {task_id}. Last response: {json.dumps(last_response, ensure_ascii=False)}")
134
+
135
+
136
+ def download_url(url: str, out: Path) -> Path:
137
+ out.parent.mkdir(parents=True, exist_ok=True)
138
+ req = urllib.request.Request(url, headers={"User-Agent": "seedaudiocli/0.1"})
139
+ last_error: urllib.error.URLError | None = None
140
+ for attempt in range(1, 4):
141
+ try:
142
+ with urllib.request.urlopen(req, timeout=300) as resp:
143
+ out.write_bytes(resp.read())
144
+ return out
145
+ except urllib.error.URLError as e:
146
+ last_error = e
147
+ if attempt < 3:
148
+ time.sleep(min(2 * attempt, 8))
149
+ raise SeedAudioError(f"Download failed: {last_error}") from last_error
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import job_store_path
9
+
10
+
11
+ TERMINAL_STATES = {"completed", "failed", "cancelled", "canceled", "expired"}
12
+
13
+
14
+ def append_job(record: dict[str, Any], path: Path | None = None) -> None:
15
+ path = path or job_store_path()
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ record = {"created_at": int(time.time()), **record}
18
+ with path.open("a", encoding="utf-8") as f:
19
+ f.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
20
+
21
+
22
+ def read_jobs(path: Path | None = None) -> list[dict[str, Any]]:
23
+ paths = [path or job_store_path()]
24
+ jobs: list[dict[str, Any]] = []
25
+ for read_path in paths:
26
+ if not read_path.exists():
27
+ continue
28
+ for line in read_path.read_text(encoding="utf-8").splitlines():
29
+ if not line.strip():
30
+ continue
31
+ try:
32
+ jobs.append(json.loads(line))
33
+ except json.JSONDecodeError:
34
+ continue
35
+ return jobs
36
+
37
+
38
+ def find_by_hash(request_hash: str) -> dict[str, Any] | None:
39
+ for job in reversed(read_jobs()):
40
+ if job.get("request_hash") == request_hash and job.get("task_id"):
41
+ return job
42
+ return None
43
+
44
+
45
+ def append_status(task_id: str, status: str, response: dict[str, Any] | None = None) -> None:
46
+ append_job({"event": "status", "task_id": task_id, "status": status, "response": response or {}})
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import default_format, default_sample_rate, seedaudio_model
9
+
10
+
11
+ FORMATS = {"wav", "mp3", "pcm", "ogg_opus"}
12
+ SAMPLE_RATES = {8000, 16000, 24000, 32000, 44100, 48000}
13
+
14
+
15
+ def read_prompt(prompt: str | None, prompt_file: str | None) -> str:
16
+ if prompt and prompt_file:
17
+ raise ValueError("Use either --prompt or --prompt-file, not both.")
18
+ if prompt_file:
19
+ return Path(prompt_file).expanduser().read_text(encoding="utf-8").strip()
20
+ if prompt:
21
+ return prompt.strip()
22
+ raise ValueError("Missing --prompt or --prompt-file.")
23
+
24
+
25
+ def build_payload(
26
+ *,
27
+ prompt: str,
28
+ model: str | None = None,
29
+ audio_references: list[str] | None = None,
30
+ image_urls: list[str] | None = None,
31
+ output_format: str | None = None,
32
+ sample_rate: int | None = None,
33
+ speech_rate: float | None = None,
34
+ loudness_rate: float | None = None,
35
+ pitch_rate: int | None = None,
36
+ callback_url: str | None = None,
37
+ ) -> dict[str, Any]:
38
+ payload: dict[str, Any] = {
39
+ "model": model or seedaudio_model(),
40
+ "prompt": prompt,
41
+ "format": output_format or default_format(),
42
+ "sample_rate": sample_rate or default_sample_rate(),
43
+ }
44
+ if audio_references:
45
+ payload["audio_references"] = audio_references
46
+ if image_urls:
47
+ payload["image_urls"] = image_urls
48
+ if speech_rate is not None:
49
+ payload["speech_rate"] = speech_rate
50
+ if loudness_rate is not None:
51
+ payload["loudness_rate"] = loudness_rate
52
+ if pitch_rate is not None:
53
+ payload["pitch_rate"] = pitch_rate
54
+ if callback_url:
55
+ payload["callback_url"] = callback_url
56
+ return payload
57
+
58
+
59
+ def validate_payload(payload: dict[str, Any]) -> list[str]:
60
+ errors: list[str] = []
61
+ prompt = payload.get("prompt")
62
+ if not isinstance(prompt, str) or not prompt.strip():
63
+ errors.append("prompt is required.")
64
+ elif len(prompt) > 1500:
65
+ errors.append("prompt must be <= 1500 characters.")
66
+
67
+ if payload.get("model") != "doubao-seed-audio-1-0":
68
+ errors.append("model must be doubao-seed-audio-1-0 unless the gateway adds another compatible model.")
69
+
70
+ fmt = payload.get("format")
71
+ if fmt not in FORMATS:
72
+ errors.append(f"format must be one of {sorted(FORMATS)}.")
73
+
74
+ sample_rate = payload.get("sample_rate")
75
+ if sample_rate not in SAMPLE_RATES:
76
+ errors.append(f"sample_rate must be one of {sorted(SAMPLE_RATES)}.")
77
+
78
+ audio_refs = payload.get("audio_references")
79
+ image_urls = payload.get("image_urls")
80
+ if audio_refs and image_urls:
81
+ errors.append("audio_references and image_urls are mutually exclusive.")
82
+ if audio_refs is not None:
83
+ if not isinstance(audio_refs, list):
84
+ errors.append("audio_references must be a list.")
85
+ elif len(audio_refs) > 3:
86
+ errors.append("audio_references supports at most 3 items.")
87
+ if image_urls is not None:
88
+ if not isinstance(image_urls, list):
89
+ errors.append("image_urls must be a list.")
90
+ elif len(image_urls) > 1:
91
+ errors.append("image_urls supports at most 1 item.")
92
+
93
+ for key in ("speech_rate", "loudness_rate"):
94
+ if key in payload:
95
+ value = payload[key]
96
+ if not isinstance(value, (int, float)) or value < 0.5 or value > 2:
97
+ errors.append(f"{key} must be between 0.5 and 2.0.")
98
+ if "pitch_rate" in payload:
99
+ value = payload["pitch_rate"]
100
+ if not isinstance(value, int) or value < -12 or value > 12:
101
+ errors.append("pitch_rate must be an integer between -12 and 12.")
102
+ return errors
103
+
104
+
105
+ def request_hash(payload: dict[str, Any]) -> str:
106
+ data = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
107
+ return hashlib.sha256(data).hexdigest()[:16]
@@ -0,0 +1,7 @@
1
+ {
2
+ "SEEDAUDIO_API_KEY": "",
3
+ "SEEDAUDIO_BASE_URL": "https://api.evolink.ai",
4
+ "SEEDAUDIO_MODEL": "doubao-seed-audio-1-0",
5
+ "SEEDAUDIO_DEFAULT_FORMAT": "mp3",
6
+ "SEEDAUDIO_DEFAULT_SAMPLE_RATE": "44100"
7
+ }
@@ -0,0 +1,10 @@
1
+ # Copy to ~/.config/seedaudiocli/seedaudiocli.env or run:
2
+ # seedaudiocli config init
3
+ #
4
+ # This is a local-only file. Do not commit real API keys.
5
+
6
+ SEEDAUDIO_API_KEY=
7
+ SEEDAUDIO_BASE_URL=https://api.evolink.ai
8
+ SEEDAUDIO_MODEL=doubao-seed-audio-1-0
9
+ SEEDAUDIO_DEFAULT_FORMAT=mp3
10
+ SEEDAUDIO_DEFAULT_SAMPLE_RATE=44100
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: seedaudiocli
3
+ description: Generate audio through EvoLink Seed Audio using the seedaudiocli CLI; import configuration files, submit explicit audio requests, recover tasks, and download results.
4
+ ---
5
+
6
+ # Seed Audio CLI
7
+
8
+ This companion Skill documents the capability contract of `@petercjl/seedaudiocli`.
9
+ Inputs are an explicit audio prompt, selected reference mode, output settings and a destination. Outputs are task IDs, status and downloaded audio paths. The calling application owns content strategy and audio acceptance; this Skill owns correct invocation and recovery.
10
+
11
+ ## Runtime and discovery
12
+
13
+ Requires Node.js 22+, Python 3.9+ with SSL, network access to EvoLink, and an authorized API key. Run `seedaudiocli version`, `seedaudiocli capabilities --json` and `seedaudiocli doctor --json`. Resolve the canonical Skill with `seedaudiocli skill source --json`; the returned `source` directory belongs to the installed npm package.
14
+
15
+ Use the current host's terminal execution capability. Its mapping is declared in [capabilities.json](capabilities.json) and the selected adapter: [Codex](adapters/codex.json) or [SealSeek](adapters/sealseek.json). Adapter mappings are implemented; host integration evidence is separate from CLI tests.
16
+
17
+ ## Main flow
18
+
19
+ 1. Check `seedaudiocli auth status`. This checks local configuration only and does not prove the key is accepted remotely.
20
+ 2. If configuration is missing, ask the user for the local path to the administrator-provided JSON or env configuration file. Import with `seedaudiocli config import "FILE"`. Pass the file path to the CLI without displaying or reading its secret contents into the conversation. If configuration already exists, preserve it; an explicitly requested replacement uses `--replace`, which creates a backup. After import, return to step 1. Interactive terminal users can run `seedaudiocli setup`.
21
+ 3. Preserve the caller's prompt, model, reference mode and output choices. Choose `generate text`, `generate audio-ref`, or `generate image-ref` according to those inputs. Prompts support at most 1500 characters; audio mode accepts up to three preset IDs or public audio URLs; image mode accepts exactly one public image URL. Local references need an authorized hosting step outside this CLI.
22
+ 4. Validate the intended request with `--dry-run`. Validation must succeed before paid submission. Configuration import and dry-run do not submit generation jobs.
23
+ 5. Execute the authorized request with `--wait --download "OUTPUT_DIRECTORY"` when local delivery is required. Save the returned task ID. Generation can incur service charges.
24
+ 6. Inspect status and actual output paths. A completed task without an audio file is incomplete delivery. Report the task ID, file and material limitations; content quality remains the caller's acceptance responsibility.
25
+
26
+ ## Invocation examples
27
+
28
+ ```bash
29
+ seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --dry-run
30
+ seedaudiocli generate text --prompt "Generate five seconds of instrumental piano." --wait --download ./audio
31
+ seedaudiocli generate audio-ref --audio-reference zh_female_vv_uranus_bigtts --prompt "@audio1 今天的咖啡刚刚好。" --wait --download ./audio
32
+ seedaudiocli generate image-ref --image-url "https://example.com/reference.jpg" --prompt "Generate audio matching the supplied reference." --dry-run
33
+ seedaudiocli task get TASK_ID
34
+ seedaudiocli task wait TASK_ID --download ./audio
35
+ seedaudiocli task jobs --limit 20
36
+ ```
37
+
38
+ Use `@audio1` through `@audio3` in prompts to address the corresponding audio references. Supported formats are mp3, wav, pcm and ogg_opus. Use the command help for sample-rate and other constraints. Explicit unsupported requests require caller resolution.
39
+
40
+ ## Error and recovery branches
41
+
42
+ - `CONFIG_REQUIRED`: request the file path, import, then return to authentication check.
43
+ - `CONFIG_INVALID` or `CONFIG_READ_FAILED`: request a corrected file/path. Preserve the saved configuration.
44
+ - `CONFIG_EXISTS`: explain that configuration already exists; replace only when the user requests it.
45
+ - `AUTH_REQUIRED`: the provider rejected credentials or permissions. Request a valid replacement or account correction, then resume the same intended operation.
46
+ - `PYTHON_REQUIRED`: install a supported Python runtime in the host's managed environment; verify with `doctor` before returning to the main flow.
47
+ - Network failure or polling timeout: retain configuration and recover using the existing task ID. Do not submit a duplicate paid job to fix a polling error.
48
+ - Unknown submission outcome: inspect task history and provider evidence before any new explicitly authorized submission. `--force` intentionally bypasses local request deduplication and can charge again.
49
+ - Provider failure: report it with the task ID. Changing prompt, references or model is a caller decision.
50
+
51
+ ## Installation and update
52
+
53
+ `seedaudiocli skill install --agent codex` installs a managed copy. For SealSeek supply the actual discovery directory with `--root SKILLS_DIRECTORY` or `SEALSEEK_SKILLS_DIR`; installation refuses to guess the host's layout. Existing unmanaged Skills are preserved. `skill status` reports ownership and freshness; `skill update` backs up managed files and preserves customized UI metadata. Edited managed content requires reconciliation.
54
+
55
+ Run `seedaudiocli update instructions` for the package update and Skill synchronization commands. Updates are explicit; configuration and history live outside the npm package and remain available afterward. Record recurring contract failures for a separately authorized package update.
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "codex",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "command.execute",
8
+ "status": "implemented",
9
+ "features": [
10
+ "argv",
11
+ "exit-code",
12
+ "stdout",
13
+ "local-files"
14
+ ],
15
+ "implementation": {
16
+ "kind": "cli",
17
+ "command": "seedaudiocli",
18
+ "discovery": "PATH; verify version and capabilities --json. Invoke through the host advertised terminal tool.",
19
+ "result": "Parse JSON stdout and exit code; preserve task_id and downloaded_to. Nonzero is failure."
20
+ }
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "sealseek",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "command.execute",
8
+ "status": "implemented",
9
+ "features": [
10
+ "argv",
11
+ "exit-code",
12
+ "stdout",
13
+ "local-files"
14
+ ],
15
+ "implementation": {
16
+ "kind": "cli",
17
+ "command": "seedaudiocli",
18
+ "discovery": "PATH; verify version and capabilities --json. Invoke through the host advertised terminal tool.",
19
+ "result": "Parse JSON stdout and exit code; preserve task_id and downloaded_to. Nonzero is failure."
20
+ }
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,3 @@
1
+ interface:
2
+ display_name: "Seed Audio"
3
+ short_description: "Generate audio and manage Seed Audio tasks"
@@ -0,0 +1,40 @@
1
+ {
2
+ "schema": "portable-skill-capabilities",
3
+ "schema_version": "1.0.0",
4
+ "target_platforms": [
5
+ "codex",
6
+ "sealseek"
7
+ ],
8
+ "capabilities": [
9
+ {
10
+ "id": "command.execute",
11
+ "purpose": "Execute the installed seedaudiocli contract and parse JSON results",
12
+ "required": true,
13
+ "required_features": [
14
+ "argv",
15
+ "exit-code",
16
+ "stdout",
17
+ "local-files"
18
+ ],
19
+ "input_fields": [
20
+ "arguments"
21
+ ],
22
+ "output_fields": [
23
+ "exit_code",
24
+ "stdout"
25
+ ],
26
+ "side_effects": {
27
+ "external_mutation": true,
28
+ "may_cost_money": true,
29
+ "authorization": "Configuration import writes local credentials; generation requires user-authorized paid service use."
30
+ },
31
+ "errors": [
32
+ "CONFIG_REQUIRED",
33
+ "CONFIG_INVALID",
34
+ "AUTH_REQUIRED",
35
+ "PYTHON_REQUIRED",
36
+ "PROVIDER_FAILURE"
37
+ ]
38
+ }
39
+ ]
40
+ }