openclacky 1.3.11 → 1.4.0

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.
@@ -60,7 +60,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
60
60
  -d '{
61
61
  "prompt": "A clean, modern hero illustration for a tech startup landing page. Soft gradient background, abstract geometric shapes in blue and purple, minimal style, 4K quality.",
62
62
  "aspect_ratio": "landscape",
63
- "output_dir": "'"$(pwd)"'"
63
+ "output_dir": "'"$(pwd)"'",
64
+ "session_id": "<%= session_id %>"
64
65
  }'
65
66
  ```
66
67
 
@@ -75,6 +76,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
75
76
  | `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
76
77
  | `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
77
78
  | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. The image is saved under `<output_dir>/assets/generated/`. |
79
+ | `session_id` | yes | string | Current Clacky session ID. Always pass the rendered value shown in the request example. |
78
80
  | `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
79
81
  | `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
80
82
 
@@ -90,7 +92,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
90
92
  -H "Content-Type: application/json" \
91
93
  -d '{
92
94
  "prompt": "change the background to a starry night sky, keep the cat unchanged",
93
- "image": "/abs/path/to/input.png"
95
+ "image": "/abs/path/to/input.png",
96
+ "session_id": "<%= session_id %>"
94
97
  }'
95
98
  ```
96
99
 
@@ -206,7 +209,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
206
209
  "prompt": "A cinematic drone shot flying over a misty mountain range at sunrise, golden light, 4K.",
207
210
  "aspect_ratio": "landscape",
208
211
  "duration_seconds": 8,
209
- "output_dir": "'"$(pwd)"'"
212
+ "output_dir": "'"$(pwd)"'",
213
+ "session_id": "<%= session_id %>"
210
214
  }'
211
215
  ```
212
216
 
@@ -217,6 +221,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
217
221
  | `duration_seconds` | no | 4–8 | Defaults to 8. |
218
222
  | `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
219
223
  | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. MP4 saved under `<output_dir>/assets/generated/`. |
224
+ | `session_id` | yes | string | Current Clacky session ID. Always pass the rendered value shown in the request example. |
220
225
 
221
226
  ### Response (success)
222
227
 
@@ -278,11 +283,11 @@ Workflow for an N-segment continuous video:
278
283
  writes a ready-to-send JSON file:
279
284
  ```bash
280
285
  "$SEQ" payload /tmp/seg2.json /tmp/seg1_last.jpg 8 landscape "$OUT_DIR" \
281
- "Continuing the same scene, the camera keeps pushing forward…"
286
+ "Continuing the same scene, the camera keeps pushing forward…" "<%= session_id %>"
282
287
  curl -s -X POST .../api/media/video -H "Content-Type: application/json" \
283
288
  --data @/tmp/seg2.json
284
289
  ```
285
- (`payload <out.json> <frame> <duration_seconds> <aspect_ratio> <output_dir> <prompt>`)
290
+ (`payload <out.json> <frame> <duration_seconds> <aspect_ratio> <output_dir> <prompt> [session_id]`)
286
291
  5. **Repeat** steps 3–4 for each subsequent segment, always chaining off the
287
292
  *previous* segment's last frame.
288
293
  6. **Stitch** all clips in order into one file:
@@ -305,6 +310,175 @@ Rules & caveats:
305
310
  chaining is the practical option today; Veo's native server-side `extend`
306
311
  (148s) is not wired into this endpoint yet.
307
312
 
313
+ ### Seedance (Volcengine Ark) — multimodal video
314
+
315
+ When the configured `type=video` model is a ByteDance **Doubao Seedance**
316
+ model on Volcengine Ark (its Base URL is under `*.volces.com`, e.g.
317
+ `https://ark.cn-beijing.volces.com/api/v3`), the **same**
318
+ `POST /api/media/video` endpoint drives it. No separate endpoint — the server
319
+ routes by Base URL automatically. Seedance adds richer inputs on top of the
320
+ common fields; all are optional and only apply to Seedance:
321
+
322
+ > **Cost gate — ask before EVERY generation.** Resolution is the main driver
323
+ > of Seedance's price (4k costs far more than 720p). So **once you've confirmed
324
+ > via `GET /api/media/types` that the `type=video` Base URL is under
325
+ > `*.volces.com`, you MUST ask the user which resolution they want before EACH
326
+ > AND EVERY billable call** — this covers not just a brand-new clip but also
327
+ > editing, multimodal reference, and extending/continuing an existing video
328
+ > (they all cost the same as a fresh render). Offer `480p` / `720p` / `1080p` /
329
+ > `4k` and state the default is `720p`. Only after they answer (or explicitly
330
+ > say "use the default") do you proceed, passing their choice as `resolution`.
331
+ > **Ask again every single time — a resolution the user picked for one clip is
332
+ > NEVER carried over to the next generation. Do not assume, do not reuse a
333
+ > prior answer, do not batch. One generation = one fresh resolution question.**
334
+ > **When editing or continuing/extending an existing video, default to that
335
+ > source video's resolution — never silently upgrade it (e.g. don't turn a
336
+ > 720p source into a 4k render).** If the user gave no answer and you didn't
337
+ > ask, the server pins `720p`. **These Seedance-only fields (`resolution`,
338
+ > `generate_audio`, `watermark`, `seed`, `first_frame`, `last_frame`,
339
+ > `reference_*`) have NO effect on Veo or Qwen/DashScope backends — never send
340
+ > them unless the Base URL is `*.volces.com`.**
341
+
342
+ | Field | Values | Notes |
343
+ |--------------------|------------------------------------------|-------|
344
+ | `aspect_ratio` | `landscape`/`portrait`/`square`, or a raw Ark ratio like `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `adaptive` | Raw ratios pass through unchanged. |
345
+ | `duration_seconds` | integer, or `-1` | `-1` lets the model pick the length (Seedance 2.0 / 1.5 Pro). |
346
+ | `resolution` | `480p` / `720p` / `1080p` / `4k` | **Defaults to `720p` when omitted** (cost control). Ask the user before every generation — never reuse a prior answer. See the cost gate above. Model-dependent; unsupported values are rejected upstream. |
347
+ | `generate_audio` | `true` / `false` | Seedance 2.0 / 1.5 Pro can synthesize a synced audio track. |
348
+ | `watermark` | `true` / `false` | |
349
+ | `seed` | integer | Reproducibility. |
350
+ | `first_frame` | media ref (see below) | First frame → image-to-video. |
351
+ | `last_frame` | media ref | Together with `first_frame` → first+last-frame video. |
352
+ | `reference_images` | array of media refs (0–9) | Reference images. |
353
+ | `reference_videos` | array of media refs (0–3) | Reference videos. |
354
+ | `reference_audios` | array of media refs (0–3) | Reference audio (background music / voice). |
355
+
356
+ **Which fields for which task** — Seedance covers six capabilities; pick the
357
+ fields by intent, and never mix the two families below:
358
+
359
+ | Task | What you want | Fields to send |
360
+ |------|---------------|----------------|
361
+ | Text-to-video | a clip from a prompt only | `prompt` (no media) |
362
+ | Image-to-video (first frame) | animate a still image forward | `first_frame` |
363
+ | Image-to-video (first + last frame) | interpolate between two stills | `first_frame` + `last_frame` |
364
+ | Multimodal generation | new clip guided by reference images/videos/audio | `reference_images` / `reference_videos` / `reference_audios` |
365
+ | **Edit an existing video** | replace/add/remove/repaint something *inside* a given video | `reference_videos: [<the video to edit>]` (+ optional `reference_images` / `reference_audios`) + a prompt describing the edit |
366
+ | **Extend / continue a video** | prepend/append or stitch clips into one | `reference_videos: [<clip1>, <clip2>, ...]` (up to 3) + a prompt describing the join |
367
+
368
+ > **🚫 Hard rule — the two families are mutually exclusive.**
369
+ > `first_frame`/`last_frame` **cannot** be combined with any `reference_*`
370
+ > field; Ark rejects the request. If the user wants to **edit or extend an
371
+ > existing video, that is a `reference_videos` task — do NOT fall back to
372
+ > extracting a frame and using `first_frame`** (that produces a brand-new clip
373
+ > and silently loses the "edit the original" intent). The server also enforces
374
+ > this and returns a clear `invalid_argument` error if you mix them.
375
+
376
+ A **media ref** may be:
377
+ - a public `http(s)://` URL, or a `data:` URL, or
378
+ - a local file path (the server reads and base64-encodes it), or
379
+ - a `{ "b64_json": "...", "mime_type": "image/png" }` hash.
380
+
381
+ Note: audio cannot be sent alone — pair it with at least one image or video.
382
+ Prefer passing large videos/audios as public URLs; base64-encoding a big local
383
+ file can exceed upstream size limits.
384
+
385
+ Example — **first + last frame** (image-to-video, no `reference_*`):
386
+
387
+ ```bash
388
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/video \
389
+ -H "Content-Type: application/json" \
390
+ -d '{
391
+ "prompt": "First-person POV, a hand raises a cup of fruit tea toward the camera, bright and refreshing lighting",
392
+ "aspect_ratio": "9:16",
393
+ "duration_seconds": 8,
394
+ "resolution": "720p",
395
+ "first_frame": "'"$(pwd)"'/assets/frame_first.jpg",
396
+ "last_frame": "'"$(pwd)"'/assets/frame_last.jpg",
397
+ "output_dir": "'"$(pwd)"'",
398
+ "session_id": "<%= session_id %>"
399
+ }'
400
+ ```
401
+
402
+ Example — **edit an existing video** (replace/add/remove something inside it;
403
+ uses `reference_videos`, NOT `first_frame`):
404
+
405
+ ```bash
406
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/video \
407
+ -H "Content-Type: application/json" \
408
+ -d '{
409
+ "prompt": "Add a small wooden fishing boat with a warm lantern drifting slowly across the lake in the foreground, keep everything else unchanged",
410
+ "resolution": "720p",
411
+ "reference_videos": ["'"$(pwd)"'/assets/original.mp4"],
412
+ "output_dir": "'"$(pwd)"'",
413
+ "session_id": "<%= session_id %>"
414
+ }'
415
+ ```
416
+
417
+ **Seedance is asynchronous — POST only submits, it does NOT return the video.**
418
+ Unlike Veo (which blocks and returns the mp4 in one call), the Seedance POST
419
+ returns immediately with a task id:
420
+
421
+ ```json
422
+ { "success": true, "status": "submitted", "task_id": "cgt-2024...-xxxx", "provider": "volcengine" }
423
+ ```
424
+
425
+ `status: "submitted"` means the render is now running on Volcengine's servers
426
+ and **is already being billed** — it does NOT mean it is done. You MUST now
427
+ poll for completion: sleep ~15 seconds, then query the status endpoint, and
428
+ repeat until it is `succeeded` (or `failed`):
429
+
430
+ ```bash
431
+ curl -s "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/video/status?task_id=cgt-2024...-xxxx&output_dir=$(pwd)&session_id=<%= session_id %>"
432
+ ```
433
+
434
+ Status responses:
435
+
436
+ ```json
437
+ { "success": true, "status": "running" } // keep polling
438
+ { "success": true, "status": "succeeded", "video": "/abs/path.mp4" } // done — this is the file
439
+ { "success": false, "status": "failed", "error": "..." } // give up, report to user
440
+ ```
441
+
442
+ Only once you receive `status: "succeeded"` and the absolute `video` path may
443
+ you present the result to the user. Do NOT end your turn while the task is
444
+ still `submitted`/`running` — the user is waiting for the finished video.
445
+
446
+ A minimal poll loop:
447
+
448
+ ```bash
449
+ TASK_ID=$(curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/video \
450
+ -H "Content-Type: application/json" \
451
+ -d '{"prompt":"...","resolution":"720p","output_dir":"'"$(pwd)"'","session_id":"<%= session_id %>"}' \
452
+ | sed -n 's/.*"task_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
453
+
454
+ while true; do
455
+ sleep 15
456
+ RESP=$(curl -s "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/video/status?task_id=${TASK_ID}&output_dir=$(pwd)&session_id=<%= session_id %>")
457
+ STATUS=$(echo "$RESP" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
458
+ echo "poll: $STATUS"
459
+ [ "$STATUS" = "succeeded" ] && echo "$RESP" && break
460
+ [ "$STATUS" = "failed" ] && echo "$RESP" && break
461
+ done
462
+ ```
463
+
464
+ **Hard rules — a broken version of this once doubled a user's bill:**
465
+
466
+ - ❌ **Never POST the same generation twice.** Once you have a `task_id`, the
467
+ only valid next action is polling `/api/media/video/status`. A slow render
468
+ is not a failed one.
469
+ - ⚠️ **A timeout or error is NOT proof the task failed.** The task keeps
470
+ running and billing on Volcengine's side. Always query the status endpoint
471
+ to find out the real state before doing anything else — never resubmit.
472
+ - ❌ **Never kill the poll to "cancel" the job.** Killing your curl/session
473
+ does not stop the Volcengine task; it keeps running and billing. A running
474
+ task also cannot be deleted upstream.
475
+ - ❌ **Never bypass `/api/media/*` to call Volcengine's native API directly.**
476
+ All submission and status checks must go through this server (it meters
477
+ cost). There is no reason to touch the raw Ark API.
478
+ - ⏱️ If polling exceeds ~15 minutes and status is still `running`, stop
479
+ polling and tell the user the task is still rendering in the background,
480
+ give them the `task_id`, and let them check again later — do NOT resubmit.
481
+
308
482
  ## Generating speech (Gemini TTS)
309
483
 
310
484
  The same `/api/media/` namespace serves text-to-speech. The user must
@@ -328,7 +502,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
328
502
  -d '{
329
503
  "input": "Hello and welcome to openclacky. Today we will explore...",
330
504
  "voice": "Kore",
331
- "output_dir": "'"$(pwd)"'"
505
+ "output_dir": "'"$(pwd)"'",
506
+ "session_id": "<%= session_id %>"
332
507
  }'
333
508
  ```
334
509
 
@@ -337,6 +512,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
337
512
  | `input` | yes | string | The text to speak. Plain prose works best; you can prefix with style cues like "Say cheerfully:" or "In a calm tone:". |
338
513
  | `voice` | no | string voice name | Defaults to `Kore`. Common Gemini voices: `Kore`, `Puck`, `Charon`, `Fenrir`, `Aoede`. |
339
514
  | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. WAV saved under `<output_dir>/assets/generated/`. |
515
+ | `session_id` | yes | string | Current Clacky session ID. Always pass the rendered value shown in the request example. |
340
516
 
341
517
  Generation typically takes 2–10 seconds depending on length. The request
342
518
  blocks until the WAV is ready.
@@ -388,4 +564,3 @@ Same shape and `error_type` values as image generation, but with `"audio": null`
388
564
  - Voice consistency: Gemini TTS does not currently support voice cloning;
389
565
  use the same `voice` name across calls in one project to keep the
390
566
  narrator consistent.
391
-
@@ -10,7 +10,7 @@
10
10
  # Subcommands:
11
11
  # lastframe <video.mp4> <out.jpg> extract the final frame (JPEG by default)
12
12
  # tob64 <image> print base64 (no newlines) to stdout
13
- # payload <out.json> <frame.jpg> <dur> <aspect> <output_dir> <prompt>
13
+ # payload <out.json> <frame.jpg> <dur> <aspect> <output_dir> <prompt> [session_id]
14
14
  # build an image-to-video JSON body
15
15
  # for `curl --data @out.json`
16
16
  # concat <out.mp4> <clip1.mp4> [clip2 …] losslessly join clips in order
@@ -43,7 +43,7 @@ cmd_tob64() {
43
43
  # Build the image-to-video request body as a file so curl can send it with
44
44
  # `--data @file`, avoiding "Argument list too long" from inlining base64.
45
45
  cmd_payload() {
46
- local out="$1" frame="$2" dur="$3" aspect="$4" odir="$5" prompt="$6"
46
+ local out="$1" frame="$2" dur="$3" aspect="$4" odir="$5" prompt="$6" session_id="${7:-}"
47
47
  [[ -f "$frame" ]] || die "no such frame: $frame"
48
48
  need ffprobe
49
49
  local mime b64
@@ -53,7 +53,7 @@ cmd_payload() {
53
53
  esac
54
54
  b64="$(base64 < "$frame" | tr -d '\n')"
55
55
  FRAME_B64="$b64" FRAME_MIME="$mime" P_PROMPT="$prompt" P_DUR="$dur" \
56
- P_ASPECT="$aspect" P_ODIR="$odir" python3 - "$out" <<'PY'
56
+ P_ASPECT="$aspect" P_ODIR="$odir" P_SESSION_ID="$session_id" python3 - "$out" <<'PY'
57
57
  import json, os, sys
58
58
  body = {
59
59
  "prompt": os.environ["P_PROMPT"],
@@ -62,6 +62,8 @@ body = {
62
62
  "output_dir": os.environ["P_ODIR"],
63
63
  "image": {"b64_json": os.environ["FRAME_B64"], "mime_type": os.environ["FRAME_MIME"]},
64
64
  }
65
+ if os.environ["P_SESSION_ID"]:
66
+ body["session_id"] = os.environ["P_SESSION_ID"]
65
67
  open(sys.argv[1], "w").write(json.dumps(body))
66
68
  PY
67
69
  [[ -f "$out" ]] || die "failed to write payload"
@@ -17,6 +17,9 @@ module Clacky
17
17
  MANIFEST = "ext.yml"
18
18
  MAX_ZIP_SIZE = 50 * 1024 * 1024
19
19
 
20
+ # Platform metadata that leaks in from the developer's OS; never ship it.
21
+ SYSTEM_METADATA = [".DS_Store", "__MACOSX", "Thumbs.db", "desktop.ini"].freeze
22
+
20
23
  Result = Struct.new(:ext_id, :path, :units, keyword_init: true)
21
24
 
22
25
  class Error < StandardError; end
@@ -111,6 +114,8 @@ module Clacky
111
114
  next if base == "." || base == ".."
112
115
 
113
116
  rel = relative(container_dir, abs)
117
+ next if system_metadata?(rel)
118
+
114
119
  entry = File.join(File.basename(container_dir), rel)
115
120
  if File.directory?(abs)
116
121
  zip.mkdir(entry) unless zip.find_entry(entry)
@@ -121,6 +126,12 @@ module Clacky
121
126
  end
122
127
  end
123
128
 
129
+ # True if any path segment is a platform metadata file/dir (e.g. a nested
130
+ # agents/.DS_Store or a whole __MACOSX/ tree).
131
+ private def system_metadata?(rel)
132
+ rel.split(File::SEPARATOR).any? { |seg| SYSTEM_METADATA.include?(seg) }
133
+ end
134
+
124
135
  private def local_zip_for(source, tmp)
125
136
  src = source.to_s
126
137
  if src.match?(%r{\Ahttps?://})
@@ -19,7 +19,7 @@ module Clacky
19
19
  KNOWN_TOP_KEYS = %w[id name title description version origin author homepage license public license_required keywords contributes].freeze
20
20
  KNOWN_CONTRIBUTES = %w[panels api skills agents channels patches hooks].freeze
21
21
 
22
- PANEL_KEYS = %w[id title title_zh description description_zh view order attach].freeze
22
+ PANEL_KEYS = %w[id title title_zh description description_zh view order attach entry_points].freeze
23
23
  API_KEYS = %w[id handler].freeze
24
24
  SKILL_KEYS = %w[id dir protected].freeze
25
25
  AGENT_KEYS = %w[id title title_zh description description_zh order prompt panels skills avatar].freeze
@@ -3,6 +3,7 @@
3
3
  require_relative "openai_compat"
4
4
  require_relative "gemini"
5
5
  require_relative "dashscope"
6
+ require_relative "volcengine"
6
7
 
7
8
  module Clacky
8
9
  module Media
@@ -34,6 +35,13 @@ module Clacky
34
35
  "aliyuncs.com"
35
36
  ].freeze
36
37
 
38
+ # Hosts that speak Volcengine Ark's native async video-task API
39
+ # (ByteDance Doubao Seedance) instead of an OpenAI-compatible facade.
40
+ # Matched as a substring so regional variants are caught.
41
+ VOLCENGINE_NATIVE_HOSTS = [
42
+ "volces.com"
43
+ ].freeze
44
+
37
45
  # @param agent_config [Clacky::AgentConfig]
38
46
  def initialize(agent_config)
39
47
  @agent_config = agent_config
@@ -101,6 +109,34 @@ module Clacky
101
109
  )
102
110
  end
103
111
 
112
+ def video_status(task_id:, output_dir: nil)
113
+ entry = video_model_entry
114
+ if entry.nil?
115
+ return {
116
+ "success" => false,
117
+ "status" => "failed",
118
+ "error" => "No video model configured. Add a model with type=video in settings.",
119
+ "error_type" => "not_configured",
120
+ "provider" => "",
121
+ "model" => ""
122
+ }
123
+ end
124
+
125
+ provider = build_provider_for(entry)
126
+ unless provider.respond_to?(:video_status)
127
+ return {
128
+ "success" => false,
129
+ "status" => "failed",
130
+ "error" => "Status queries are only supported for Volcengine (Seedance) video; this provider generates synchronously.",
131
+ "error_type" => "unsupported",
132
+ "provider" => "",
133
+ "model" => ""
134
+ }
135
+ end
136
+
137
+ provider.video_status(task_id: task_id, output_dir: output_dir)
138
+ end
139
+
104
140
  def generate_speech(input:, voice: nil, output_dir: nil, **kwargs)
105
141
  entry = audio_model_entry
106
142
  if entry.nil?
@@ -185,6 +221,9 @@ module Clacky
185
221
  # DashScope (native /api/v1/.../multimodal-generation schema for
186
222
  # Qwen-Image). Third-party aggregators re-exposing qwen-image behind
187
223
  # an OpenAI-compatible facade are NOT on aliyuncs.com and fall through.
224
+ # • base_url points at a Volcengine Ark host (*.volces.com) →
225
+ # Volcengine (native async /api/v3/contents/generations/tasks schema
226
+ # for Doubao Seedance video).
188
227
  # • everything else → OpenAICompat. This covers OpenAI itself, the
189
228
  # openclacky gateway, OpenRouter, and any third-party proxy that
190
229
  # re-exposes Gemini / Imagen / DALL-E behind /v1/images/generations.
@@ -196,6 +235,8 @@ module Clacky
196
235
  Gemini.new(entry)
197
236
  elsif DASHSCOPE_NATIVE_HOSTS.any? { |host| url.include?(host) }
198
237
  DashScope.new(entry)
238
+ elsif VOLCENGINE_NATIVE_HOSTS.any? { |host| url.include?(host) }
239
+ Volcengine.new(entry)
199
240
  else
200
241
  OpenAICompat.new(entry)
201
242
  end