@optima-chat/optima-agent 0.8.92 → 0.8.93

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kol-outreach
3
- description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio email、发送个性化 outreach、自动谈判价格与条款。当用户说'帮我找 KOL'、'{产品} KOL 建联'、'KOL 进展如何',或收到系统触发的 [KOL_INBOUND:...] 标记时,使用此 skill。"
3
+ description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio email、发送个性化 outreach、自动谈判价格与条款。当用户说'帮我找 KOL'、'{产品} KOL 建联'、'KOL 进展如何',或收到系统触发的 [KOL_INBOUND:...] 或 [KOL_INBOUND] 标记时,使用此 skill。"
4
4
  ---
5
5
 
6
6
  # KOL Outreach Skill
@@ -31,7 +31,7 @@ description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio
31
31
 
32
32
  按顺序执行这些检查,决定本次 session 的动作:
33
33
 
34
- 1. **检测系统触发**:用户消息是否包含 `[KOL_INBOUND:{campaignId}]` 标记?
34
+ 1. **检测系统触发**:用户消息是否包含 `[KOL_INBOUND:{campaignId}]` 或 `[KOL_INBOUND]`(无 campaignId)标记?
35
35
  - 是 → 走「KOL 回信处理」流程(见下)
36
36
  - 否 → 继续步骤 2
37
37
  2. **检测首次使用**:`ls ~/kol-outreach/BRAND.md` 是否存在?
@@ -217,16 +217,27 @@ description: "KOL 建联全自动化:从 TikTok 发现潜在 KOL、提取 bio
217
217
 
218
218
  ---
219
219
 
220
- ## 流程 D:KOL 回信处理(由 `[KOL_INBOUND:{campaignId}]` 触发)
220
+ ## 流程 D:KOL 回信处理(由 `[KOL_INBOUND:{campaignId}]` 或 `[KOL_INBOUND]` 触发)
221
221
 
222
- 1. 解析 prompt:从消息里抠 campaignId 和 email metadata(From / Subject / Message-ID / In-Reply-To / References / Body)
222
+ 1. 解析 prompt
223
+ - 如果包含 `[KOL_INBOUND:{campaignId}]` → 直接知道 campaignId,同时抠 email metadata(From / Subject / Message-ID / In-Reply-To / References / Body)
224
+ - 如果包含 `[KOL_INBOUND]` + `merchant_id: {X}` → campaignId 未知,抠 merchant_id 和 email metadata;campaignId 在步骤 3 中搜索确定
225
+ (`merchant_id` 仅用于日志和审计。campaign 查找基于 `~/kol-outreach/campaigns/*/KOLS.md` 遍历,因为工作目录已隐含了 merchant 身份。)
223
226
 
224
- 2. 校验 campaign 存在:
227
+ 2. 校验 campaign 存在(仅 campaignId 已知时):
225
228
  - `ls ~/kol-outreach/campaigns/{campaignId}/CONFIG.md`
226
229
  - 不存在 → 写 merchant PROGRESS.md 异常记录 + `sentinel report --condition-met false --summary "unknown campaign"` + 退出
227
230
 
228
- 3. `KOLS.md`,按 from_email 找对应 username
229
- - 找不到 orphan_inbound:写 campaign PROGRESS.md + sentinel report false + 退出
231
+ 3. from_email 查找 KOL:
232
+ - **campaignId 已知**(from step 1):只读该 campaign 的 KOLS.md
233
+ - 找到 → 继续步骤 4
234
+ - 找不到 → orphan_inbound:写 campaign PROGRESS.md + sentinel report false + 退出
235
+ - **campaignId 未知**(fallback):遍历 `~/kol-outreach/campaigns/*/KOLS.md`
236
+ - 唯一匹配 → 确定 campaignId,继续步骤 4
237
+ - 多个匹配 → 选 **KOLS.md 中该 email 对应条目的 `last_update` 字段**最新的那个 campaign
238
+ - 无匹配 → orphan_inbound:写 merchant PROGRESS.md + sentinel report false + 退出
239
+
240
+ ※ 同一 campaign 内如果同一 email 出现多条记录(例如 KOL 换 username 后被重新添加),取 status 非终态(非 rejected / expired / no_contact)的活跃记录。
230
241
 
231
242
  4. 读本 campaign 的 `CONFIG.md`、`BRAND.md`、`CONVERSATIONS/{username}.md`
232
243
 
@@ -90,7 +90,7 @@ save_workflow.py (Phase 5 — 可选,效果好时沉淀)
90
90
 
91
91
  **仅做轻量元数据识别,不执行工具调用**。不下载视频、不调 ffprobe、不抽帧。
92
92
 
93
- 按需提问(不一次全问):产品是什么、替换目标、音频需求(有音频 ~$1,无音频 ~$0.02)、时长期望。
93
+ 按需提问(不一次全问):产品是什么、替换目标、音频需求(有音频 ~$1.50/10s,无音频 ~$0.02/10s,扣 Optima credits 由服务端中间件完成)、时长期望。
94
94
 
95
95
  先查 `gen-output/video-clone/workflows/README.md` — 完全匹配 → 复用;部分匹配 → 调整;
96
96
  无匹配 → 走通用 pipeline。详见 [workflow-system.md](references/workflow-system.md)。
@@ -1,7 +1,8 @@
1
- # Kling 3.0 via PiAPI — what the script handles + what you need to know
1
+ # Video generation with audio — what the script handles + what you need to know
2
2
 
3
- The full pipeline (frame upload submit task → poll → download with
4
- retry) lives in `scripts/kling_generate.py`. Run it, don't hand-roll it.
3
+ The `kling_generate.py` script calls the **Optima generation backend**, which
4
+ routes to Kling 3.0 internally. The script itself knows nothing about the
5
+ upstream provider — only the backend does.
5
6
 
6
7
  ```bash
7
8
  python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
@@ -9,44 +10,76 @@ python scripts/kling_generate.py --project <name> --frame <confirmed-frame.png>
9
10
  # --cfg-scale 0.5 --no-audio
10
11
  ```
11
12
 
12
- ## When to use Kling vs `gen video`
13
+ ## When to use `kling_generate.py` vs `gen_video.py`
13
14
 
14
15
  | Need audio / lip sync? | Use |
15
16
  |---|---|
16
- | Yes | `kling_generate.py` (Kling 3.0, ~$1 per 10s) |
17
- | No | `gen_video.py` (Wan 2.6, ~$0.02 per 10s) |
17
+ | Yes | `kling_generate.py` ($0.15/s ≈ $1.50 per 10s equivalent) |
18
+ | No | `gen_video.py` (~$0.02 per 10s equivalent) |
18
19
 
19
- ## Non-obvious traps (all already handled by the script)
20
+ Both scripts go through the same generation backend and the same billing
21
+ middleware — the difference is server-side provider selection.
20
22
 
21
- - `cfg_scale` **must be float**. Passing a string makes PiAPI coerce it to
22
- `500` which produces nonsense. The script uses `float(cfg)`.
23
- - Status field is lowercase `"completed"` (not `"Completed"`).
24
- - Output field differs by version: 3.0 returns `output.video`, 2.6 returns
25
- `output.video_url`. Script hardcodes 3.0's `output.video`.
26
- - `enable_audio` is 3.0-only — no-op on 2.6.
27
- - PiAPI CDN drops large downloads; the script retries 3× with 5s backoff.
28
- - Kling rejects base64 in `image_url`. The script uploads to
29
- freeimage.host first to get a public URL. Do not switch to catbox.moe
30
- (PiAPI's servers can't reach it).
23
+ ## Auth + API URL discovery
31
24
 
32
- ## Prompt sourcing
25
+ `kling_generate.py` mirrors the `@optima-chat/gen-cli` auth convention, so
26
+ any user who has run `optima login` is automatically ready — no extra env
27
+ setup needed.
33
28
 
34
- The script reads the prompt from `<project>/prompt.md`. If you want to
35
- use a different prompt for a test run, edit `prompt.md` (it's versioned
36
- via `log.md` in the project dir, so edits are recoverable).
29
+ Token resolution (first match wins):
37
30
 
38
- ## Negative prompt (hardcoded in script)
31
+ 1. `OPTIMA_TOKEN` env var
32
+ 2. `~/.optima/token.json` (`access_token` field)
39
33
 
40
- ```
41
- slow motion, dreamy, ethereal, cinematic, blurry,
42
- distorted, deformed hands, extra fingers
43
- ```
34
+ Generation API URL resolution (first match wins):
35
+
36
+ 1. `GENERATION_API_URL` env var
37
+ 2. `~/.optima/token.json` `env` field → `ci` / `stage` / `prod` mapping
38
+ 3. default: `https://gen-api.optima.onl` (prod)
39
+
40
+ If neither token source resolves, the script exits 1 with a clear hint
41
+ (`Run `optima login` or set OPTIMA_TOKEN`). **No fallback to direct upstream
42
+ calls** — that would bypass billing.
43
+
44
+ Run `scripts/preflight.py` to verify the auth state and other deps.
45
+
46
+ The `requests` Python package is also required.
47
+
48
+ ## Billing
49
+
50
+ Billing is entirely server-side. When `kling_generate.py` calls
51
+ `POST /api/video/generate`, the backend's billing middleware pre-deducts
52
+ credits from the user's Optima wallet based on `metadata.duration`. If the
53
+ upstream task later fails, the server refunds the credits automatically
54
+ (see `billingClient.refund()` in the generation service worker).
55
+
56
+ This means the skill never sees raw USD pricing — it just sends a request
57
+ and waits. The user's wallet is the source of truth for how much was spent.
58
+
59
+ ## Error handling
60
+
61
+ `_submit()` translates the common billing errors into readable messages:
62
+
63
+ - HTTP 402 `INSUFFICIENT_CREDITS` — user does not have enough credits
64
+ - HTTP 403 `PLAN_RESTRICTED` — user's plan does not include video generation
65
+
66
+ Other HTTP errors propagate via `raise_for_status()`. Polling uses the
67
+ backend's unified `GET /api/task/{id}` endpoint.
68
+
69
+ ## Non-obvious traps (now handled server-side)
70
+
71
+ The `cfg_scale`-must-be-float / lowercase-status / 3.0-vs-2.6 response
72
+ differences / freeimage-vs-catbox quirks are all handled by the server's
73
+ PiAPI adapter (`optima-gen: packages/generation/src/adapters/piapi-video.ts`).
74
+ The skill no longer has to know about them.
44
75
 
45
- Changing this requires editing `kling_generate.py` directly it's
46
- deliberately not a CLI flag because the same negatives apply to every run.
76
+ ## What changed vs the old direct-PiAPI version
47
77
 
48
- ## Required environment
78
+ Before: script uploaded to freeimage.host, submitted to PiAPI, polled PiAPI,
79
+ downloaded from Kling CDN. No billing. Shared PiAPI key hardcoded in the
80
+ skill.
49
81
 
50
- - `PIAPI_KEY` env var run `scripts/preflight.py` to verify
51
- - `requests` Python package the script imports lazily and prints a
52
- clear error if missing
82
+ After: script sends one POST + polls one endpoint on the Optima backend.
83
+ Billing is automatic. No upstream API keys in the skill. Provider switches
84
+ (if the Kling API ever changes) happen server-side without touching any
85
+ client code.
@@ -1,21 +1,34 @@
1
- """Phase 3: generate video via Kling 3.0 over PiAPI.
1
+ """Phase 3: generate video with audio via the Optima generation backend.
2
+
3
+ The generation backend routes to Kling 3.0 (PiAPI) when provider="piapi".
4
+ Billing is automatic — the server's billing middleware deducts credits from
5
+ the user's Optima wallet at submit time and refunds on task failure.
2
6
 
3
7
  Usage:
4
8
  python scripts/kling_generate.py --project <name> --frame <path> \
5
9
  [--duration 10] [--aspect-ratio 9:16] [--mode std] \
6
10
  [--cfg-scale 0.5] [--no-audio]
7
11
 
8
- Pipeline (ported verbatim from references/kling-api.md):
9
- 1. Gate: requires preview_confirmed.
10
- 2. Upload first frame to freeimage.host → get public URL.
11
- 3. POST /api/v1/task to PiAPI with kling model + 3.0 params.
12
- 4. Poll /api/v1/task/{id} every 15s until status=='completed'.
13
- 5. Download mp4 with 3-attempt retry (5s backoff).
12
+ Auth & API URL discovery (matches @optima-chat/gen-cli convention):
13
+ Token:
14
+ 1. OPTIMA_TOKEN env var
15
+ 2. ~/.optima/token.json ("access_token" field)
16
+ Generation API URL:
17
+ 1. GENERATION_API_URL env var
18
+ 2. ~/.optima/token.json ("env" field) → ci/stage/prod mapping
19
+ 3. default: prod (https://gen-api.optima.onl)
20
+
21
+ A logged-in user (`optima login`) has both automatically; no server-side
22
+ env injection is required.
14
23
 
15
- Reads prompt from <project>/prompt.md. Writes output to <project>/videos/
16
- with a versioned filename.
24
+ Pipeline:
25
+ 1. Gate: requires preview_confirmed.
26
+ 2. POST <GEN_URL>/api/video/generate with provider="piapi" + base64 frame.
27
+ Server-side billing middleware pre-deducts credits here.
28
+ 3. Poll GET <GEN_URL>/api/task/{id} every 10s until completed/failed.
29
+ 4. Download result_url to <project>/videos/ (3-attempt retry).
17
30
 
18
- Requires: PIAPI_KEY env var. Run `python scripts/preflight.py` first if unsure.
31
+ Reads prompt from <project>/prompt.md. Writes output with a versioned filename.
19
32
  """
20
33
  from __future__ import annotations
21
34
 
@@ -30,82 +43,143 @@ from pathlib import Path
30
43
  from _gate import require_gate
31
44
  from _project import resolve_project, next_version, append_log
32
45
 
33
- # Lazy import of requests so py_compile / gate checks work without the dep.
34
46
  try:
35
47
  import requests # type: ignore
36
48
  except ImportError: # pragma: no cover
37
49
  requests = None
38
50
 
39
- FREEIMAGE_KEY = "6d207e02198a847aa98d0a2a901485a5"
40
- FREEIMAGE_URL = "https://freeimage.host/api/1/upload"
41
- PIAPI_BASE = "https://api.piapi.ai/api/v1"
42
- DEFAULT_NEGATIVE = (
43
- "slow motion, dreamy, ethereal, cinematic, blurry, "
44
- "distorted, deformed hands, extra fingers"
45
- )
51
+ POLL_INTERVAL_S = 10
52
+ POLL_TIMEOUT_S = 15 * 60 # 15 min (server side uses 10 min, leave 5 min headroom)
53
+ DOWNLOAD_RETRIES = 3
54
+ DOWNLOAD_BACKOFF_S = 5
46
55
 
56
+ TOKEN_FILE = Path.home() / ".optima" / "token.json"
57
+ API_URLS = {
58
+ "prod": "https://gen-api.optima.onl",
59
+ "stage": "https://gen-api.stage.optima.onl",
60
+ }
47
61
 
48
- def _upload_frame(frame: Path) -> str:
49
- img_b64 = base64.b64encode(frame.read_bytes()).decode()
50
- r = requests.post(
51
- FREEIMAGE_URL,
52
- data={"key": FREEIMAGE_KEY, "action": "upload",
53
- "source": img_b64, "format": "json"},
54
- timeout=60,
62
+
63
+ def _load_token_file() -> dict | None:
64
+ """Read ~/.optima/token.json if present. Returns parsed dict or None."""
65
+ if not TOKEN_FILE.is_file():
66
+ return None
67
+ try:
68
+ data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
69
+ except (OSError, json.JSONDecodeError):
70
+ return None
71
+ if not data.get("access_token"):
72
+ return None
73
+ return data
74
+
75
+
76
+ def _get_token() -> str:
77
+ """Resolve the Optima auth token.
78
+
79
+ Order matches @optima-chat/gen-cli: env var > token.json. Fail fast with
80
+ a login hint if neither is available.
81
+ """
82
+ env_token = os.environ.get("OPTIMA_TOKEN", "").strip()
83
+ if env_token:
84
+ return env_token
85
+ data = _load_token_file()
86
+ if data:
87
+ return data["access_token"]
88
+ print(
89
+ "ERROR: Optima token not found. Set OPTIMA_TOKEN or run `optima login`.",
90
+ file=sys.stderr,
55
91
  )
56
- r.raise_for_status()
57
- data = r.json()
58
- return data["image"]["url"]
92
+ sys.exit(1)
93
+
59
94
 
95
+ def _get_gen_api_url() -> str:
96
+ """Resolve the generation service base URL.
60
97
 
61
- def _submit(api_key: str, prompt: str, image_url: str,
98
+ Order matches @optima-chat/gen-cli: GENERATION_API_URL > token.json env
99
+ field > prod default.
100
+ """
101
+ override = os.environ.get("GENERATION_API_URL", "").strip()
102
+ if override:
103
+ return override.rstrip("/")
104
+ data = _load_token_file()
105
+ env = (data or {}).get("env") or "prod"
106
+ return API_URLS.get(env, API_URLS["prod"])
107
+
108
+
109
+ def _auth_header(token: str) -> str:
110
+ """Accept either 'Bearer xxx' or a raw token; normalize to Bearer form."""
111
+ return token if token.lower().startswith("bearer ") else f"Bearer {token}"
112
+
113
+
114
+ def _submit(gen_url: str, auth: str, prompt: str, frame_b64: str,
62
115
  duration: int, aspect: str, mode: str,
63
116
  cfg: float, audio: bool) -> str:
117
+ """POST /api/video/generate with provider='piapi'. Returns backend task_id.
118
+
119
+ On billing failure the server returns 402 (INSUFFICIENT_CREDITS) or
120
+ 403 (PLAN_RESTRICTED); both are surfaced as actionable errors.
121
+ """
64
122
  payload = {
65
- "model": "kling",
66
- "task_type": "video_generation",
67
- "input": {
68
- "prompt": prompt,
69
- "negative_prompt": DEFAULT_NEGATIVE,
70
- "image_url": image_url,
71
- "duration": duration,
72
- "aspect_ratio": aspect,
73
- "mode": mode,
74
- "version": "3.0",
75
- "cfg_scale": float(cfg), # MUST be float
76
- "enable_audio": audio,
77
- },
78
- "config": {"service_mode": "public"},
123
+ "provider": "piapi",
124
+ "image": frame_b64,
125
+ "prompt": prompt,
126
+ "duration": duration,
127
+ "aspect_ratio": aspect,
128
+ "mode": mode,
129
+ "cfg_scale": float(cfg),
130
+ "enable_audio": audio,
79
131
  }
80
132
  r = requests.post(
81
- f"{PIAPI_BASE}/task",
82
- headers={"x-api-key": api_key, "Content-Type": "application/json"},
133
+ f"{gen_url}/api/video/generate",
134
+ headers={"Authorization": auth, "Content-Type": "application/json"},
83
135
  json=payload, timeout=60,
84
136
  )
137
+ if r.status_code == 402:
138
+ raise RuntimeError(f"INSUFFICIENT_CREDITS: {r.json().get('error', {}).get('message', 'not enough credits')}")
139
+ if r.status_code == 403:
140
+ raise RuntimeError(f"PLAN_RESTRICTED: {r.json().get('error', {}).get('message', 'plan does not include video generation')}")
85
141
  r.raise_for_status()
86
- return r.json()["data"]["task_id"]
142
+ return r.json()["task_id"]
143
+
87
144
 
145
+ def _poll(gen_url: str, auth: str, task_id: str) -> tuple[str, dict]:
146
+ """Poll /api/task/{id} until completed or failed. Returns (result_url, result_meta).
88
147
 
89
- def _poll(api_key: str, task_id: str) -> str:
148
+ On client-side timeout the task may still complete server-side (credits
149
+ already deducted). The task_id is included in the timeout message so the
150
+ caller can re-check later via GET /api/task/<id>.
151
+ """
152
+ start = time.monotonic()
90
153
  while True:
154
+ if time.monotonic() - start > POLL_TIMEOUT_S:
155
+ raise RuntimeError(
156
+ f"polling timed out after {POLL_TIMEOUT_S}s (task_id={task_id}). "
157
+ f"Task may still complete server-side — query "
158
+ f"GET /api/task/{task_id} to check status and retrieve the result."
159
+ )
91
160
  r = requests.get(
92
- f"{PIAPI_BASE}/task/{task_id}",
93
- headers={"x-api-key": api_key}, timeout=60,
161
+ f"{gen_url}/api/task/{task_id}",
162
+ headers={"Authorization": auth}, timeout=60,
94
163
  )
95
164
  r.raise_for_status()
96
- d = r.json().get("data", {})
165
+ d = r.json()
97
166
  status = d.get("status", "")
98
167
  if status == "completed":
99
- return d["output"]["video"] # 3.0 uses output.video
168
+ url = d.get("result_url")
169
+ if not url:
170
+ raise RuntimeError(
171
+ f"task {task_id} completed but result_url missing from response"
172
+ )
173
+ return url, d.get("result_meta") or {}
100
174
  if status == "failed":
101
- raise RuntimeError(d.get("error", d))
102
- print(f" status: {status} — polling again in 15s")
103
- time.sleep(15)
175
+ raise RuntimeError(d.get("error_message") or f"task {task_id} failed without error message")
176
+ print(f" status: {status} — polling again in {POLL_INTERVAL_S}s")
177
+ time.sleep(POLL_INTERVAL_S)
104
178
 
105
179
 
106
180
  def _download(url: str, out: Path) -> None:
107
181
  last_err = None
108
- for attempt in range(3):
182
+ for attempt in range(DOWNLOAD_RETRIES):
109
183
  try:
110
184
  r = requests.get(url, timeout=300)
111
185
  r.raise_for_status()
@@ -113,9 +187,9 @@ def _download(url: str, out: Path) -> None:
113
187
  return
114
188
  except Exception as e:
115
189
  last_err = e
116
- print(f" download attempt {attempt + 1}/3 failed: {e}")
117
- time.sleep(5)
118
- raise RuntimeError(f"download failed after 3 attempts: {last_err}")
190
+ print(f" download attempt {attempt + 1}/{DOWNLOAD_RETRIES} failed: {e}")
191
+ time.sleep(DOWNLOAD_BACKOFF_S)
192
+ raise RuntimeError(f"download failed after {DOWNLOAD_RETRIES} attempts: {last_err}")
119
193
 
120
194
 
121
195
  def main() -> int:
@@ -137,10 +211,8 @@ def main() -> int:
137
211
  file=sys.stderr)
138
212
  return 1
139
213
 
140
- api_key = os.environ.get("PIAPI_KEY", "2fccd94d5825b15840a27b8110e077b29ee3adb38c62fedd95d9e922ad440954")
141
- if not api_key:
142
- print("ERROR: PIAPI_KEY env var not set. See preflight.py.", file=sys.stderr)
143
- return 1
214
+ gen_url = _get_gen_api_url()
215
+ auth = _auth_header(_get_token())
144
216
 
145
217
  frame = Path(args.frame)
146
218
  if not frame.is_file():
@@ -153,28 +225,36 @@ def main() -> int:
153
225
  return 1
154
226
  prompt = prompt_path.read_text(encoding="utf-8").strip()
155
227
 
156
- print("Uploading frame to freeimage.host …")
157
- image_url = _upload_frame(frame)
158
- print(f" image_url: {image_url}")
228
+ frame_b64 = base64.b64encode(frame.read_bytes()).decode()
159
229
 
160
230
  print("Submitting video generation task …")
161
- task_id = _submit(
162
- api_key, prompt, image_url,
163
- duration=args.duration, aspect=args.aspect_ratio, mode=args.mode,
164
- cfg=args.cfg_scale, audio=not args.no_audio,
165
- )
231
+ try:
232
+ task_id = _submit(
233
+ gen_url, auth, prompt, frame_b64,
234
+ duration=args.duration, aspect=args.aspect_ratio, mode=args.mode,
235
+ cfg=args.cfg_scale, audio=not args.no_audio,
236
+ )
237
+ except RuntimeError as e:
238
+ print(f"ERROR: {e}", file=sys.stderr)
239
+ return 1
166
240
  print(f" task_id: {task_id}")
167
241
 
168
242
  print("Polling for completion …")
169
- video_url = _poll(api_key, task_id)
170
- print(f" video_url: {video_url}")
243
+ try:
244
+ result_url, result_meta = _poll(gen_url, auth, task_id)
245
+ except RuntimeError as e:
246
+ print(f"ERROR: {e}", file=sys.stderr)
247
+ return 1
248
+ print(f" result_url: {result_url}")
249
+ if result_meta:
250
+ print(f" result_meta: {result_meta}")
171
251
 
172
252
  out = next_version(project_dir / "videos", f"video_{args.duration}s_", ".mp4")
173
253
  print(f"Downloading → {out}")
174
- _download(video_url, out)
254
+ _download(result_url, out)
175
255
 
176
256
  print(f"\nDone: {out}")
177
- append_log(project_dir, f"video_generated_with_audio → {out.name}")
257
+ append_log(project_dir, f"video_generated_with_audio → {out.name} (task {task_id})")
178
258
  return 0
179
259
 
180
260
 
@@ -0,0 +1,191 @@
1
+ """Tests for the pure helper functions in kling_generate.py.
2
+
3
+ No network. We do not exercise _submit / _poll / _download — those are
4
+ I/O-bound and covered by end-to-end staging runs. Unit tests here keep the
5
+ helper contracts honest so refactors don't silently break the auth-header
6
+ normalization or the token / API-URL discovery order.
7
+
8
+ Run: python scripts/kling_generate_test.py
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ SCRIPTS_DIR = Path(__file__).parent
21
+ sys.path.insert(0, str(SCRIPTS_DIR))
22
+
23
+ import kling_generate # noqa: E402
24
+
25
+
26
+ # ---------- _auth_header ----------
27
+
28
+ def test_auth_header_adds_bearer_prefix():
29
+ assert kling_generate._auth_header("xyz123") == "Bearer xyz123"
30
+
31
+
32
+ def test_auth_header_preserves_existing_bearer():
33
+ assert kling_generate._auth_header("Bearer xyz123") == "Bearer xyz123"
34
+
35
+
36
+ def test_auth_header_is_case_insensitive_on_existing_prefix():
37
+ assert kling_generate._auth_header("bearer xyz123") == "bearer xyz123"
38
+ assert kling_generate._auth_header("BEARER xyz123") == "BEARER xyz123"
39
+
40
+
41
+ # ---------- _get_token ----------
42
+
43
+ def test_get_token_prefers_env_var(tmp_token_file):
44
+ tmp_token_file.write_text(
45
+ json.dumps({"access_token": "from-file", "env": "prod"}), encoding="utf-8"
46
+ )
47
+ os.environ["OPTIMA_TOKEN"] = "from-env"
48
+ try:
49
+ assert kling_generate._get_token() == "from-env"
50
+ finally:
51
+ os.environ.pop("OPTIMA_TOKEN", None)
52
+
53
+
54
+ def test_get_token_falls_back_to_file(tmp_token_file):
55
+ os.environ.pop("OPTIMA_TOKEN", None)
56
+ tmp_token_file.write_text(
57
+ json.dumps({"access_token": "file-xyz", "env": "stage"}), encoding="utf-8"
58
+ )
59
+ assert kling_generate._get_token() == "file-xyz"
60
+
61
+
62
+ def test_get_token_exits_when_neither_source_has_token(tmp_token_file):
63
+ os.environ.pop("OPTIMA_TOKEN", None)
64
+ # tmp_token_file does not exist (fixture creates parent, caller decides)
65
+ # Here: don't create the file — _load_token_file should return None
66
+ code = (
67
+ "import sys; sys.path.insert(0, r'{d}'); import kling_generate; "
68
+ "kling_generate._get_token()"
69
+ ).format(d=str(SCRIPTS_DIR))
70
+ env = {**os.environ}
71
+ env.pop("OPTIMA_TOKEN", None)
72
+ env["HOME"] = str(tmp_token_file.parent.parent) # .../.optima/token.json → HOME = .../
73
+ env["USERPROFILE"] = env["HOME"]
74
+ result = subprocess.run(
75
+ [sys.executable, "-c", code], capture_output=True, text=True, env=env,
76
+ )
77
+ assert result.returncode == 1
78
+ assert "optima login" in result.stderr or "OPTIMA_TOKEN" in result.stderr
79
+
80
+
81
+ # ---------- _get_gen_api_url ----------
82
+
83
+ def test_get_gen_api_url_env_override_wins(tmp_token_file):
84
+ tmp_token_file.write_text(
85
+ json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
86
+ )
87
+ os.environ["GENERATION_API_URL"] = "https://custom.example/api"
88
+ try:
89
+ assert kling_generate._get_gen_api_url() == "https://custom.example/api"
90
+ finally:
91
+ os.environ.pop("GENERATION_API_URL", None)
92
+
93
+
94
+ def test_get_gen_api_url_trims_trailing_slash():
95
+ os.environ["GENERATION_API_URL"] = "https://custom.example/"
96
+ try:
97
+ assert kling_generate._get_gen_api_url() == "https://custom.example"
98
+ finally:
99
+ os.environ.pop("GENERATION_API_URL", None)
100
+
101
+
102
+ def test_get_gen_api_url_reads_env_from_token_file(tmp_token_file):
103
+ os.environ.pop("GENERATION_API_URL", None)
104
+ tmp_token_file.write_text(
105
+ json.dumps({"access_token": "t", "env": "stage"}), encoding="utf-8"
106
+ )
107
+ assert kling_generate._get_gen_api_url() == "https://gen-api.stage.optima.onl"
108
+
109
+
110
+ def test_get_gen_api_url_defaults_to_prod_when_no_file(tmp_token_file):
111
+ os.environ.pop("GENERATION_API_URL", None)
112
+ # tmp_token_file not created → should default to prod
113
+ assert kling_generate._get_gen_api_url() == "https://gen-api.optima.onl"
114
+
115
+
116
+ # ---------- fixture ----------
117
+
118
+ class _TmpToken:
119
+ """Manage a throwaway ~/.optima/token.json by monkey-patching the module constant."""
120
+ def __init__(self):
121
+ self.tmp = tempfile.TemporaryDirectory()
122
+ self.path = Path(self.tmp.name) / ".optima" / "token.json"
123
+ self.path.parent.mkdir(parents=True)
124
+ self._orig = kling_generate.TOKEN_FILE
125
+ kling_generate.TOKEN_FILE = self.path
126
+
127
+ def write_text(self, *a, **kw):
128
+ self.path.write_text(*a, **kw)
129
+
130
+ @property
131
+ def parent(self):
132
+ return self.path.parent
133
+
134
+ def cleanup(self):
135
+ kling_generate.TOKEN_FILE = self._orig
136
+ self.tmp.cleanup()
137
+
138
+
139
+ def _with_tmp_token_file(fn):
140
+ """Decorator-ish: pass a fresh _TmpToken to the test, then clean up."""
141
+ def wrapped():
142
+ h = _TmpToken()
143
+ try:
144
+ return fn(h)
145
+ finally:
146
+ h.cleanup()
147
+ return wrapped
148
+
149
+
150
+ # Rebind each test to wrap with the fixture
151
+ test_get_token_prefers_env_var = _with_tmp_token_file(test_get_token_prefers_env_var)
152
+ test_get_token_falls_back_to_file = _with_tmp_token_file(test_get_token_falls_back_to_file)
153
+ test_get_token_exits_when_neither_source_has_token = _with_tmp_token_file(test_get_token_exits_when_neither_source_has_token)
154
+ test_get_gen_api_url_env_override_wins = _with_tmp_token_file(test_get_gen_api_url_env_override_wins)
155
+ test_get_gen_api_url_reads_env_from_token_file = _with_tmp_token_file(test_get_gen_api_url_reads_env_from_token_file)
156
+ test_get_gen_api_url_defaults_to_prod_when_no_file = _with_tmp_token_file(test_get_gen_api_url_defaults_to_prod_when_no_file)
157
+
158
+
159
+ TESTS = [
160
+ ("auth_header adds Bearer prefix", test_auth_header_adds_bearer_prefix),
161
+ ("auth_header preserves Bearer", test_auth_header_preserves_existing_bearer),
162
+ ("auth_header case-insensitive on prefix", test_auth_header_is_case_insensitive_on_existing_prefix),
163
+ ("get_token: env wins over file", test_get_token_prefers_env_var),
164
+ ("get_token: falls back to token.json", test_get_token_falls_back_to_file),
165
+ ("get_token: exits 1 when neither source", test_get_token_exits_when_neither_source_has_token),
166
+ ("get_gen_api_url: env override wins", test_get_gen_api_url_env_override_wins),
167
+ ("get_gen_api_url: trims trailing slash", test_get_gen_api_url_trims_trailing_slash),
168
+ ("get_gen_api_url: reads env from token.json", test_get_gen_api_url_reads_env_from_token_file),
169
+ ("get_gen_api_url: defaults to prod", test_get_gen_api_url_defaults_to_prod_when_no_file),
170
+ ]
171
+
172
+
173
+ def main() -> int:
174
+ failed = 0
175
+ for name, fn in TESTS:
176
+ try:
177
+ fn()
178
+ print(f"PASS {name}")
179
+ except AssertionError as e:
180
+ failed += 1
181
+ print(f"FAIL {name}: {e}")
182
+ except Exception as e:
183
+ failed += 1
184
+ print(f"ERROR {name}: {type(e).__name__}: {e}")
185
+ print()
186
+ print(f"{len(TESTS) - failed}/{len(TESTS)} passed")
187
+ return 0 if failed == 0 else 1
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
@@ -1,9 +1,10 @@
1
1
  """Pre-flight environment check for the video-clone skill.
2
2
 
3
3
  Verifies the external dependencies the executor scripts will need:
4
- - `gen` CLI — gen image / gen video
4
+ - `gen` CLI — gen image / gen video (routed through generation backend)
5
5
  - `ffmpeg` / `ffprobe`
6
- - PIAPI_KEY env var for Kling 3.0
6
+ - Optima auth — either OPTIMA_TOKEN env var or ~/.optima/token.json
7
+ (matches @optima-chat/gen-cli convention)
7
8
  - Python >= 3.10
8
9
 
9
10
  Exits 0 if all checks pass, 1 otherwise. On failure, prints a human-readable
@@ -14,10 +15,14 @@ No state, no side effects.
14
15
  """
15
16
  from __future__ import annotations
16
17
 
18
+ import json
17
19
  import os
18
20
  import shutil
19
21
  import subprocess
20
22
  import sys
23
+ from pathlib import Path
24
+
25
+ TOKEN_FILE = Path.home() / ".optima" / "token.json"
21
26
 
22
27
 
23
28
  def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
@@ -35,17 +40,22 @@ def _check_binary(name: str, fix_hint: str) -> tuple[bool, str]:
35
40
  return True, f"{name}: OK ({path}) {head}".strip()
36
41
 
37
42
 
38
- _BUILTIN_KEYS = {
39
- "PIAPI_KEY": "2fccd94d5825b15840a27b8110e077b29ee3adb38c62fedd95d9e922ad440954",
40
- }
41
-
42
-
43
- def _check_env(name: str, fix_hint: str) -> tuple[bool, str]:
44
- val = os.environ.get(name) or _BUILTIN_KEYS.get(name)
45
- if not val:
46
- return False, f"${name}: NOT SET. {fix_hint}"
47
- src = "env" if os.environ.get(name) else "builtin"
48
- return True, f"${name}: OK ({src}, len={len(val)})"
43
+ def _check_optima_auth() -> tuple[bool, str]:
44
+ """Token comes from OPTIMA_TOKEN env var OR ~/.optima/token.json (gen-cli convention)."""
45
+ if os.environ.get("OPTIMA_TOKEN"):
46
+ return True, "Optima token: OK (source=env OPTIMA_TOKEN)"
47
+ if TOKEN_FILE.is_file():
48
+ try:
49
+ data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
50
+ if data.get("access_token"):
51
+ env = data.get("env", "prod")
52
+ return True, f"Optima token: OK (source={TOKEN_FILE}, env={env})"
53
+ except (OSError, json.JSONDecodeError):
54
+ pass
55
+ return (
56
+ False,
57
+ f"Optima token: NOT FOUND. Run `optima login` or set OPTIMA_TOKEN env var.",
58
+ )
49
59
 
50
60
 
51
61
  def _check_python() -> tuple[bool, str]:
@@ -65,10 +75,7 @@ CHECKS = [
65
75
  ),
66
76
  lambda: _check_binary("ffmpeg", "Install ffmpeg and ensure it is on PATH."),
67
77
  lambda: _check_binary("ffprobe", "Install ffmpeg (bundles ffprobe)."),
68
- lambda: _check_env(
69
- "PIAPI_KEY",
70
- "Required for Kling 3.0 via PiAPI. Set with: export PIAPI_KEY=sk-...",
71
- ),
78
+ lambda: _check_optima_auth(),
72
79
  ]
73
80
 
74
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/optima-agent",
3
- "version": "0.8.92",
3
+ "version": "0.8.93",
4
4
  "description": "基于 Claude Agent SDK 的电商运营 AI 助手",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",