@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,386 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from . import evolink
13
+ from .config import (
14
+ DEFAULT_BASE_URL,
15
+ DEFAULT_MODEL,
16
+ config_path,
17
+ job_store_path,
18
+ seedaudio_api_key,
19
+ seedaudio_base_url,
20
+ seedaudio_model,
21
+ write_default_env,
22
+ import_config,
23
+ ConfigError,
24
+ )
25
+ from .jobs import append_job, append_status, find_by_hash, read_jobs
26
+ from .payloads import build_payload, read_prompt, request_hash, validate_payload
27
+
28
+
29
+ def print_json(data: Any) -> None:
30
+ print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True))
31
+
32
+
33
+ def add_generation_args(parser: argparse.ArgumentParser) -> None:
34
+ parser.add_argument("--prompt", help="Prompt text, max 1500 characters.")
35
+ parser.add_argument("--prompt-file", help="Path to a UTF-8 prompt text file.")
36
+ parser.add_argument("--model", help="Override model id. Default: doubao-seed-audio-1-0.")
37
+ parser.add_argument("--format", choices=["wav", "mp3", "pcm", "ogg_opus"], help="Output audio format.")
38
+ parser.add_argument("--sample-rate", type=int, choices=[8000, 16000, 24000, 32000, 44100, 48000])
39
+ parser.add_argument("--speech-rate", type=float, help="Speech-rate multiplier from 0.5 to 2.0.")
40
+ parser.add_argument("--loudness-rate", type=float, help="Loudness multiplier from 0.5 to 2.0.")
41
+ parser.add_argument("--pitch-rate", type=int, help="Pitch adjustment in semitones from -12 to 12.")
42
+ parser.add_argument("--callback-url", help="HTTPS callback URL for task completion.")
43
+ parser.add_argument("--dry-run", action="store_true", help="Print payload without creating a task.")
44
+ parser.add_argument("--force", action="store_true", help="Create a new task even if the same request hash exists.")
45
+ parser.add_argument("--wait", action="store_true", help="Wait until the task reaches a terminal state.")
46
+ parser.add_argument("--interval", type=int, default=5, help="Polling interval in seconds for --wait.")
47
+ parser.add_argument("--timeout", type=int, default=1800, help="Polling timeout in seconds for --wait.")
48
+ parser.add_argument("--download", help="Download completed audio to this directory. Implies --wait.")
49
+ parser.add_argument("--output-name", help="Filename stem for downloaded audio. Defaults to task id.")
50
+
51
+
52
+ def generation_payload_from_args(args: argparse.Namespace) -> dict[str, Any]:
53
+ prompt = read_prompt(args.prompt, args.prompt_file)
54
+ audio_refs = list(getattr(args, "audio_reference", None) or [])
55
+ image_urls = list(getattr(args, "image_url", None) or [])
56
+ return build_payload(
57
+ prompt=prompt,
58
+ model=args.model,
59
+ audio_references=audio_refs,
60
+ image_urls=image_urls,
61
+ output_format=args.format,
62
+ sample_rate=args.sample_rate,
63
+ speech_rate=args.speech_rate,
64
+ loudness_rate=args.loudness_rate,
65
+ pitch_rate=args.pitch_rate,
66
+ callback_url=args.callback_url,
67
+ )
68
+
69
+
70
+ def download_from_response(response: dict[str, Any], out_dir: Path, output_name: str | None = None) -> list[str]:
71
+ urls = evolink.extract_audio_urls(response)
72
+ if not urls:
73
+ return []
74
+ formats: list[str | None] = []
75
+ for item in response.get("result_data") or []:
76
+ if isinstance(item, dict):
77
+ formats.append(item.get("format"))
78
+ out_dir.mkdir(parents=True, exist_ok=True)
79
+ task_id = str(response.get("id") or "audio")
80
+ downloaded: list[str] = []
81
+ for idx, url in enumerate(urls, 1):
82
+ result_format = formats[idx - 1] if idx - 1 < len(formats) else None
83
+ ext = {"ogg_opus": "ogg", "pcm": "pcm"}.get(str(result_format), str(result_format or "")) or evolink.infer_extension(url)
84
+ stem = output_name or task_id
85
+ suffix = "" if len(urls) == 1 else f"_{idx}"
86
+ out_path = out_dir / f"{stem}{suffix}.{ext}"
87
+ evolink.download_url(url, out_path)
88
+ downloaded.append(str(out_path))
89
+ return downloaded
90
+
91
+
92
+ def submit_or_print(payload: dict[str, Any], args: argparse.Namespace) -> int:
93
+ errors = validate_payload(payload)
94
+ h = request_hash(payload)
95
+ if errors:
96
+ result: dict[str, Any] = {"ok": False, "errors": errors}
97
+ if args.dry_run:
98
+ result["payload"] = payload
99
+ result["request_hash"] = h
100
+ print_json(result)
101
+ return 2
102
+ if args.dry_run:
103
+ print_json({"ok": True, "dry_run": True, "request_hash": h, "payload": payload})
104
+ return 0
105
+
106
+ existing = find_by_hash(h)
107
+ if existing and not args.force:
108
+ print_json({"ok": True, "deduped": True, "request_hash": h, "job": existing})
109
+ return 0
110
+
111
+ response = evolink.create_task(payload)
112
+ task_id = evolink.extract_task_id(response)
113
+ append_job({"event": "create", "request_hash": h, "task_id": task_id, "payload": payload, "response": response})
114
+
115
+ result: dict[str, Any] = {"ok": True, "request_hash": h, "task_id": task_id, "response": response}
116
+ should_wait = args.wait or bool(args.download)
117
+ if should_wait and task_id:
118
+ final = evolink.wait_for_task(task_id, interval=args.interval, timeout=args.timeout)
119
+ status = evolink.extract_status(final)
120
+ append_status(task_id, status, final)
121
+ result["final_status"] = status
122
+ result["final_response"] = final
123
+ if args.download:
124
+ downloaded = download_from_response(final, Path(args.download).expanduser(), args.output_name)
125
+ result["downloaded_to"] = downloaded
126
+ print_json(result)
127
+ if result.get("final_status") and result["final_status"] != "completed":
128
+ return 2
129
+ return 0
130
+
131
+
132
+ def cmd_config(args: argparse.Namespace) -> int:
133
+ if args.config_cmd == "import":
134
+ print_json(import_config(args.file, replace=args.replace))
135
+ return 0
136
+ if args.config_cmd == "path":
137
+ print(str(config_path()))
138
+ return 0
139
+ if args.config_cmd == "init":
140
+ path = write_default_env(
141
+ import_current_env=args.import_env,
142
+ import_key_file=args.import_key_file,
143
+ overwrite=args.overwrite,
144
+ )
145
+ print(f"Wrote config: {path}")
146
+ return 0
147
+ raise SystemExit("unknown config command")
148
+
149
+
150
+ def cmd_setup(args: argparse.Namespace) -> int:
151
+ if args.file:
152
+ print_json(import_config(args.file, replace=args.replace))
153
+ return 0
154
+ if seedaudio_api_key():
155
+ print_json({"ok": True, "configured": True, "config_path": str(config_path()), "remote_verified": False})
156
+ return 0
157
+ if not sys.stdin.isatty():
158
+ raise ConfigError("CONFIG_REQUIRED", "Ask the user for the configuration file path, then run seedaudiocli config import FILE.")
159
+ print("请输入管理员提供的配置文件路径(文件内容不会显示):", file=sys.stderr)
160
+ source = input().strip().strip('"').strip("'")
161
+ if not source:
162
+ raise ConfigError("CONFIG_REQUIRED", "A configuration file path is required.")
163
+ print_json(import_config(source, replace=args.replace))
164
+ return 0
165
+
166
+
167
+ def cmd_auth(args: argparse.Namespace) -> int:
168
+ key = seedaudio_api_key()
169
+ data: dict[str, Any] = {
170
+ "ok": bool(key),
171
+ "config_path": str(config_path()),
172
+ "base_url": seedaudio_base_url(),
173
+ "model": seedaudio_model(),
174
+ "api_key_source": "process-env" if os.environ.get("SEEDAUDIO_API_KEY") else ("config-file" if key else "missing"),
175
+ "api_key_present": bool(key),
176
+ "remote_verified": False,
177
+ }
178
+ if args.check_remote and key:
179
+ payload = build_payload(
180
+ prompt="Generate one second of quiet neutral test audio. No vocals.",
181
+ output_format="mp3",
182
+ sample_rate=44100,
183
+ )
184
+ data["remote_payload_valid"] = not validate_payload(payload)
185
+ data["remote_check_note"] = "Use `generate text --wait --download` for a live billing test."
186
+ print_json(data)
187
+ return 0 if key else 1
188
+
189
+
190
+ def cmd_models(_: argparse.Namespace) -> int:
191
+ print_json(
192
+ {
193
+ "default": DEFAULT_MODEL,
194
+ "configured": seedaudio_model(),
195
+ "base_url_default": DEFAULT_BASE_URL,
196
+ "notes": "EvoLink currently exposes doubao-seed-audio-1-0 for this CLI.",
197
+ }
198
+ )
199
+ return 0
200
+
201
+
202
+ def cmd_generate(args: argparse.Namespace) -> int:
203
+ payload = generation_payload_from_args(args)
204
+ return submit_or_print(payload, args)
205
+
206
+
207
+ def cmd_task(args: argparse.Namespace) -> int:
208
+ if args.task_cmd == "get":
209
+ response = evolink.get_task(args.task_id)
210
+ append_status(args.task_id, evolink.extract_status(response), response)
211
+ print_json(response)
212
+ return 0
213
+ if args.task_cmd == "wait":
214
+ response = evolink.wait_for_task(args.task_id, interval=args.interval, timeout=args.timeout)
215
+ status = evolink.extract_status(response)
216
+ append_status(args.task_id, status, response)
217
+ result: dict[str, Any] = {"task_id": args.task_id, "status": status, "response": response}
218
+ if args.download:
219
+ result["downloaded_to"] = download_from_response(response, Path(args.download).expanduser(), args.output_name)
220
+ print_json(result)
221
+ return 0 if status == "completed" else 2
222
+ if args.task_cmd == "url":
223
+ response = evolink.get_task(args.task_id)
224
+ status = evolink.extract_status(response)
225
+ append_status(args.task_id, status, response)
226
+ result: dict[str, Any] = {"task_id": args.task_id, "status": status, "urls": evolink.extract_audio_urls(response)}
227
+ if args.include_response:
228
+ result["response"] = response
229
+ print_json(result)
230
+ return 0 if result["urls"] else 2
231
+ if args.task_cmd == "jobs":
232
+ print_json({"job_store": str(job_store_path()), "jobs": read_jobs()[-args.limit :]})
233
+ return 0
234
+ raise SystemExit("unknown task command")
235
+
236
+
237
+ def cmd_payload(args: argparse.Namespace) -> int:
238
+ payload = json.loads(Path(args.file).expanduser().read_text(encoding="utf-8"))
239
+ if args.payload_cmd == "validate":
240
+ errors = validate_payload(payload)
241
+ print_json({"ok": not errors, "errors": errors})
242
+ return 0 if not errors else 2
243
+ if args.payload_cmd == "print":
244
+ print_json(payload)
245
+ return 0
246
+ if args.payload_cmd == "submit":
247
+ submit_args = SimpleNamespace(
248
+ dry_run=False,
249
+ force=args.force,
250
+ wait=args.wait,
251
+ interval=args.interval,
252
+ timeout=args.timeout,
253
+ download=args.download,
254
+ output_name=args.output_name,
255
+ )
256
+ return submit_or_print(payload, submit_args)
257
+ raise SystemExit("unknown payload command")
258
+
259
+
260
+ def build_parser() -> argparse.ArgumentParser:
261
+ parser = argparse.ArgumentParser(prog="seedaudiocli", description="System CLI for EvoLink Doubao Seed-Audio 1.0 generation.")
262
+ parser.add_argument("--version", action="version", version=f"seedaudiocli {__version__}")
263
+ sub = parser.add_subparsers(dest="cmd", required=True)
264
+
265
+ setup = sub.add_parser("setup", help="Import credentials once, or prompt for a configuration file path.")
266
+ setup.add_argument("--file")
267
+ setup.add_argument("--replace", action="store_true", help="Back up existing configuration before replacement.")
268
+ setup.set_defaults(func=cmd_setup)
269
+
270
+ config = sub.add_parser("config", help="Manage local env config.")
271
+ config_sub = config.add_subparsers(dest="config_cmd", required=True)
272
+ config_sub.add_parser("path", help="Print env config path.")
273
+ config_import = config_sub.add_parser("import", help="Import a JSON or KEY=value configuration file.")
274
+ config_import.add_argument("file")
275
+ config_import.add_argument("--replace", action="store_true", help="Back up existing configuration before replacement.")
276
+ config_init = config_sub.add_parser("init", help="Create env config file.")
277
+ config_init.add_argument("--import-env", action="store_true", help="Copy current SEEDAUDIO_* values into the env file.")
278
+ config_init.add_argument("--import-key-file", help="Import Seed-Audio EvoLink key block from a local note file.")
279
+ config_init.add_argument("--overwrite", action="store_true", help="Overwrite existing env file.")
280
+ config.set_defaults(func=cmd_config)
281
+
282
+ auth = sub.add_parser("auth", help="Check auth configuration.")
283
+ auth_sub = auth.add_subparsers(dest="auth_cmd", required=True)
284
+ status = auth_sub.add_parser("status", help="Show auth status without printing secrets.")
285
+ status.add_argument("--check-remote", action="store_true", help="Validate a remote-check payload without billing.")
286
+ status.set_defaults(func=cmd_auth)
287
+
288
+ models = sub.add_parser("models", help="Model helpers.")
289
+ models_sub = models.add_subparsers(dest="models_cmd", required=True)
290
+ models_list = models_sub.add_parser("list", help="List built-in model ids.")
291
+ models_list.set_defaults(func=cmd_models)
292
+
293
+ generate = sub.add_parser("generate", help="Create Seed-Audio generation tasks.")
294
+ gen_sub = generate.add_subparsers(dest="generate_cmd", required=True)
295
+
296
+ gen_text = gen_sub.add_parser("text", help="Text-to-audio generation.")
297
+ add_generation_args(gen_text)
298
+ gen_text.set_defaults(func=cmd_generate)
299
+
300
+ gen_audio = gen_sub.add_parser("audio-ref", help="Reference-audio or preset-voice generation.")
301
+ add_generation_args(gen_audio)
302
+ gen_audio.add_argument("--audio-reference", action="append", required=True, help="Preset voice_type or reference-audio URL. Repeat up to 3 times.")
303
+ gen_audio.set_defaults(func=cmd_generate)
304
+
305
+ gen_image = gen_sub.add_parser("image-ref", help="Reference-image-guided audio generation.")
306
+ add_generation_args(gen_image)
307
+ gen_image.add_argument("--image-url", action="append", required=True, help="Public jpeg/png/webp image URL. Exactly one is supported by the API.")
308
+ gen_image.set_defaults(func=cmd_generate)
309
+
310
+ task = sub.add_parser("task", help="Task status, wait, URLs, and history.")
311
+ task_sub = task.add_subparsers(dest="task_cmd", required=True)
312
+ task_get = task_sub.add_parser("get", help="Fetch raw task JSON.")
313
+ task_get.add_argument("task_id")
314
+ task_get.set_defaults(func=cmd_task)
315
+
316
+ task_wait = task_sub.add_parser("wait", help="Wait for task completion.")
317
+ task_wait.add_argument("task_id")
318
+ task_wait.add_argument("--interval", type=int, default=5)
319
+ task_wait.add_argument("--timeout", type=int, default=1800)
320
+ task_wait.add_argument("--download", help="Download completed audio to this directory.")
321
+ task_wait.add_argument("--output-name", help="Filename stem for downloaded audio.")
322
+ task_wait.set_defaults(func=cmd_task)
323
+
324
+ task_url = task_sub.add_parser("url", help="Print completed audio URLs.")
325
+ task_url.add_argument("task_id")
326
+ task_url.add_argument("--include-response", action="store_true")
327
+ task_url.set_defaults(func=cmd_task)
328
+
329
+ task_jobs = task_sub.add_parser("jobs", help="Show local task history.")
330
+ task_jobs.add_argument("--limit", type=int, default=20)
331
+ task_jobs.set_defaults(func=cmd_task)
332
+
333
+ payload = sub.add_parser("payload", help="Validate or submit raw JSON payloads.")
334
+ payload_sub = payload.add_subparsers(dest="payload_cmd", required=True)
335
+ payload_validate = payload_sub.add_parser("validate")
336
+ payload_validate.add_argument("file")
337
+ payload_validate.set_defaults(func=cmd_payload)
338
+ payload_print = payload_sub.add_parser("print")
339
+ payload_print.add_argument("file")
340
+ payload_print.set_defaults(func=cmd_payload)
341
+ payload_submit = payload_sub.add_parser("submit")
342
+ payload_submit.add_argument("file")
343
+ payload_submit.add_argument("--force", action="store_true")
344
+ payload_submit.add_argument("--wait", action="store_true")
345
+ payload_submit.add_argument("--interval", type=int, default=5)
346
+ payload_submit.add_argument("--timeout", type=int, default=1800)
347
+ payload_submit.add_argument("--download")
348
+ payload_submit.add_argument("--output-name")
349
+ payload_submit.set_defaults(func=cmd_payload)
350
+
351
+ return parser
352
+
353
+
354
+ def main(argv: list[str] | None = None) -> int:
355
+ parser = build_parser()
356
+ args = parser.parse_args(argv)
357
+ try:
358
+ network = ((args.cmd == "generate" and not args.dry_run)
359
+ or (args.cmd == "task" and args.task_cmd != "jobs")
360
+ or (args.cmd == "payload" and args.payload_cmd == "submit"))
361
+ if network and not seedaudio_api_key():
362
+ if sys.stdin.isatty():
363
+ cmd_setup(SimpleNamespace(file=None, replace=False))
364
+ else:
365
+ raise ConfigError("CONFIG_REQUIRED", "Ask the user for the configuration file path, then run seedaudiocli config import FILE.")
366
+ return int(args.func(args))
367
+ except ConfigError as e:
368
+ print_json({"ok": False, "code": e.code, "error": str(e)})
369
+ return 2
370
+ except (ValueError, evolink.SeedAudioError) as e:
371
+ message = str(e)
372
+ try:
373
+ key = seedaudio_api_key()
374
+ if key:
375
+ message = message.replace(key, "[REDACTED]")
376
+ except ConfigError:
377
+ pass
378
+ print_json({"ok": False, "code": getattr(e, "code", "REQUEST_FAILED"), "error": message})
379
+ return 1
380
+ except (OSError, EOFError, KeyboardInterrupt):
381
+ print_json({"ok": False, "code": "LOCAL_OPERATION_FAILED", "error": "Local operation failed or was cancelled."})
382
+ return 1
383
+
384
+
385
+ if __name__ == "__main__":
386
+ sys.exit(main())
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import stat
6
+ import tempfile
7
+ import uuid
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+
12
+ APP_NAME = "seedaudiocli"
13
+ DEFAULT_BASE_URL = "https://api.evolink.ai"
14
+ DEFAULT_MODEL = "doubao-seed-audio-1-0"
15
+ DEFAULT_FORMAT = "mp3"
16
+ DEFAULT_SAMPLE_RATE = "44100"
17
+
18
+
19
+ def home() -> Path:
20
+ return Path.home()
21
+
22
+
23
+ def config_dir() -> Path:
24
+ if os.environ.get("SEEDAUDIO_CONFIG_DIR"):
25
+ return Path(os.environ["SEEDAUDIO_CONFIG_DIR"]).expanduser()
26
+ if os.name == "nt":
27
+ return Path(os.environ.get("APPDATA", home() / "AppData" / "Roaming")) / APP_NAME
28
+ return home() / ".config" / APP_NAME
29
+
30
+
31
+ def config_path() -> Path:
32
+ return config_dir() / "seedaudiocli.env"
33
+
34
+
35
+ def state_dir() -> Path:
36
+ if os.environ.get("SEEDAUDIO_STATE_DIR"):
37
+ return Path(os.environ["SEEDAUDIO_STATE_DIR"]).expanduser()
38
+ if os.name == "nt":
39
+ return Path(os.environ.get("LOCALAPPDATA", home() / "AppData" / "Local")) / APP_NAME
40
+ return home() / ".local" / "state" / APP_NAME
41
+
42
+
43
+ class ConfigError(ValueError):
44
+ def __init__(self, code: str, message: str):
45
+ super().__init__(message)
46
+ self.code = code
47
+
48
+
49
+ ALLOWED = {"SEEDAUDIO_API_KEY", "SEEDAUDIO_BASE_URL", "SEEDAUDIO_MODEL",
50
+ "SEEDAUDIO_DEFAULT_FORMAT", "SEEDAUDIO_DEFAULT_SAMPLE_RATE"}
51
+
52
+
53
+ def validate_config(values: dict[str, str], require_key: bool = True) -> dict[str, str]:
54
+ if not isinstance(values, dict) or set(values) - ALLOWED:
55
+ raise ConfigError("CONFIG_INVALID", "Configuration contains unsupported fields.")
56
+ if any(not isinstance(v, str) or any(ord(c) < 32 for c in v) for v in values.values()):
57
+ raise ConfigError("CONFIG_INVALID", "Configuration values must be single-line strings.")
58
+ key = values.get("SEEDAUDIO_API_KEY", "").strip()
59
+ if require_key and (not key or key.lower() in {"your-api-key", "replace-me", "changeme"}):
60
+ raise ConfigError("CONFIG_INVALID", "A non-empty API key is required.")
61
+ if key and (any(c.isspace() for c in key) or not key.isascii()):
62
+ raise ConfigError("CONFIG_INVALID", "API key must contain only non-space ASCII characters.")
63
+ base = values.get("SEEDAUDIO_BASE_URL", DEFAULT_BASE_URL)
64
+ try:
65
+ parsed = urlparse(base)
66
+ valid_url = parsed.scheme == "https" and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
67
+ parsed.port
68
+ except ValueError:
69
+ valid_url = False
70
+ if not valid_url:
71
+ raise ConfigError("CONFIG_INVALID", "API base URL must be HTTPS without credentials, query or fragment.")
72
+ if values.get("SEEDAUDIO_DEFAULT_FORMAT", DEFAULT_FORMAT) not in {"mp3", "wav", "pcm", "ogg_opus"}:
73
+ raise ConfigError("CONFIG_INVALID", "Unsupported audio format.")
74
+ if values.get("SEEDAUDIO_DEFAULT_SAMPLE_RATE", DEFAULT_SAMPLE_RATE) not in {"8000", "16000", "24000", "32000", "44100", "48000"}:
75
+ raise ConfigError("CONFIG_INVALID", "Unsupported sample rate.")
76
+ if not values.get("SEEDAUDIO_MODEL", DEFAULT_MODEL).strip():
77
+ raise ConfigError("CONFIG_INVALID", "Model must not be empty.")
78
+ return {**values, "SEEDAUDIO_API_KEY": key}
79
+
80
+
81
+ def parse_config_text(text: str, require_key: bool = True) -> dict[str, str]:
82
+ if text.lstrip().startswith("{"):
83
+ try:
84
+ values = json.loads(text)
85
+ except (ValueError, TypeError):
86
+ raise ConfigError("CONFIG_INVALID", "Invalid JSON configuration.") from None
87
+ return validate_config(values, require_key=require_key)
88
+ values = {}
89
+ for raw in text.splitlines():
90
+ line = raw.strip()
91
+ if not line or line.startswith("#"):
92
+ continue
93
+ if "=" not in line:
94
+ raise ConfigError("CONFIG_INVALID", "Expected KEY=value configuration data.")
95
+ key, value = line.split("=", 1)
96
+ key, value = key.strip(), value.strip()
97
+ if len(value) >= 2 and value[0] in "\"'" and value[-1] == value[0]:
98
+ value = value[1:-1]
99
+ if key in values:
100
+ raise ConfigError("CONFIG_INVALID", "Duplicate configuration field.")
101
+ values[key] = value
102
+ return validate_config(values, require_key=require_key)
103
+
104
+
105
+ def save_config(values: dict[str, str], replace: bool = False, require_key: bool = True) -> dict:
106
+ values = validate_config(values, require_key=require_key)
107
+ target = config_path()
108
+ if target.is_symlink() or (target.exists() and not target.is_file()):
109
+ raise ConfigError("CONFIG_UNSAFE_TARGET", "Configuration target must be a regular file.")
110
+ if target.exists() and not replace:
111
+ raise ConfigError("CONFIG_EXISTS", "Configuration already exists; use --replace to back it up and replace it.")
112
+ target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
113
+ lock = target.parent / ".config-import.lock"
114
+ try:
115
+ lock_fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
116
+ except FileExistsError:
117
+ raise ConfigError("CONFIG_BUSY", "Another configuration import holds the lock.") from None
118
+ backup = None
119
+ temp = None
120
+ try:
121
+ os.close(lock_fd)
122
+ if target.is_symlink() or (target.exists() and not target.is_file()):
123
+ raise ConfigError("CONFIG_UNSAFE_TARGET", "Configuration target must be a regular file.")
124
+ if target.exists():
125
+ if not replace:
126
+ raise ConfigError("CONFIG_EXISTS", "Configuration already exists; use --replace.")
127
+ backup = target.with_name(target.name + ".backup-" + uuid.uuid4().hex)
128
+ with open(backup, "xb") as out:
129
+ os.chmod(backup, 0o600)
130
+ out.write(target.read_bytes())
131
+ content = "\n".join(f"{k}={v}" for k, v in sorted(values.items())) + "\n"
132
+ fd, name = tempfile.mkstemp(prefix=".config-", dir=target.parent)
133
+ temp = Path(name)
134
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
135
+ out.write(content)
136
+ out.flush()
137
+ os.fsync(out.fileno())
138
+ os.replace(temp, target)
139
+ os.chmod(target, 0o600)
140
+ return {"ok": True, "config_path": str(target), "backup": str(backup) if backup else None,
141
+ "api_key_present": bool(values.get("SEEDAUDIO_API_KEY")), "remote_verified": False}
142
+ finally:
143
+ if temp and temp.exists():
144
+ temp.unlink()
145
+ lock.unlink()
146
+
147
+
148
+ def import_config(source: str, replace: bool = False) -> dict:
149
+ path = Path(source).expanduser()
150
+ try:
151
+ info = path.stat()
152
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 65536:
153
+ raise ConfigError("CONFIG_INVALID", "Configuration must be a regular file under 64 KiB.")
154
+ values = parse_config_text(path.read_text(encoding="utf-8-sig"))
155
+ except (OSError, UnicodeError):
156
+ raise ConfigError("CONFIG_READ_FAILED", "Cannot read the configuration file as UTF-8.") from None
157
+ return save_config(values, replace=replace)
158
+
159
+
160
+ def job_store_path() -> Path:
161
+ return state_dir() / "jobs.jsonl"
162
+
163
+
164
+ def load_env_file(path: Path | None = None) -> dict[str, str]:
165
+ path = path or config_path()
166
+ if not path.exists():
167
+ return {}
168
+ try:
169
+ text = path.read_text(encoding="utf-8-sig")
170
+ except (OSError, UnicodeError):
171
+ raise ConfigError("CONFIG_READ_FAILED", "Cannot read saved configuration.") from None
172
+ return parse_config_text(text, require_key=False)
173
+
174
+
175
+ def get_setting(name: str, default: str | None = None) -> str | None:
176
+ value = os.environ.get(name)
177
+ if value:
178
+ return value
179
+ return load_env_file().get(name, default)
180
+
181
+
182
+ def seedaudio_api_key() -> str | None:
183
+ return get_setting("SEEDAUDIO_API_KEY")
184
+
185
+
186
+ def seedaudio_base_url() -> str:
187
+ raw = get_setting("SEEDAUDIO_BASE_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL
188
+ raw = raw.rstrip("/")
189
+ suffix = "/v1/audios/generations"
190
+ if raw.endswith(suffix):
191
+ raw = raw[: -len(suffix)]
192
+ if raw.endswith("/v1"):
193
+ raw = raw[: -len("/v1")]
194
+ return raw.rstrip("/")
195
+
196
+
197
+ def seedaudio_model() -> str:
198
+ return get_setting("SEEDAUDIO_MODEL", DEFAULT_MODEL) or DEFAULT_MODEL
199
+
200
+
201
+ def default_format() -> str:
202
+ return get_setting("SEEDAUDIO_DEFAULT_FORMAT", DEFAULT_FORMAT) or DEFAULT_FORMAT
203
+
204
+
205
+ def default_sample_rate() -> int:
206
+ value = get_setting("SEEDAUDIO_DEFAULT_SAMPLE_RATE", DEFAULT_SAMPLE_RATE) or DEFAULT_SAMPLE_RATE
207
+ return int(value)
208
+
209
+
210
+ def parse_evolink_seed_audio_file(path: Path) -> tuple[str | None, str | None]:
211
+ if not path.exists():
212
+ return None, None
213
+ text = path.read_text(errors="ignore")
214
+ in_seed = False
215
+ api_key: str | None = None
216
+ base_url: str | None = None
217
+ for raw in text.splitlines():
218
+ line = raw.strip()
219
+ low = line.lower()
220
+ if "seed-audio" in low and "evolink" in low:
221
+ in_seed = True
222
+ continue
223
+ if not in_seed:
224
+ continue
225
+ norm = line.replace(":", ":")
226
+ if norm.lower().startswith("base url") and ":" in norm:
227
+ base_url = norm.split(":", 1)[1].strip()
228
+ elif norm.lower().startswith("api key") and ":" in norm:
229
+ api_key = norm.split(":", 1)[1].strip()
230
+ break
231
+ return api_key, base_url
232
+
233
+
234
+ def write_default_env(
235
+ import_current_env: bool = False,
236
+ import_key_file: str | None = None,
237
+ overwrite: bool = False,
238
+ ) -> Path:
239
+ path = config_path()
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ if path.exists() and not overwrite:
242
+ return path
243
+
244
+ current_key = os.environ.get("SEEDAUDIO_API_KEY", "") if import_current_env else ""
245
+ current_base = os.environ.get("SEEDAUDIO_BASE_URL", DEFAULT_BASE_URL) if import_current_env else DEFAULT_BASE_URL
246
+ current_model = os.environ.get("SEEDAUDIO_MODEL", DEFAULT_MODEL) if import_current_env else DEFAULT_MODEL
247
+ current_format = os.environ.get("SEEDAUDIO_DEFAULT_FORMAT", DEFAULT_FORMAT) if import_current_env else DEFAULT_FORMAT
248
+ current_sample_rate = (
249
+ os.environ.get("SEEDAUDIO_DEFAULT_SAMPLE_RATE", DEFAULT_SAMPLE_RATE)
250
+ if import_current_env
251
+ else DEFAULT_SAMPLE_RATE
252
+ )
253
+
254
+ if import_key_file:
255
+ key, base = parse_evolink_seed_audio_file(Path(import_key_file).expanduser())
256
+ current_key = key or current_key
257
+ current_base = base or current_base
258
+
259
+ save_config({"SEEDAUDIO_API_KEY": current_key, "SEEDAUDIO_BASE_URL": current_base,
260
+ "SEEDAUDIO_MODEL": current_model, "SEEDAUDIO_DEFAULT_FORMAT": current_format,
261
+ "SEEDAUDIO_DEFAULT_SAMPLE_RATE": current_sample_rate}, replace=overwrite, require_key=False)
262
+ return path