openclacky 1.5.8 → 1.5.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efbdc6d887e5beea867023b733d023f236b0e0a0a813201cce3767deebe43fd2
4
- data.tar.gz: 1abf4430324ddd28b1d1d6a6b82390eaa8c76dc026f31cc1c10ff7fdac6c7207
3
+ metadata.gz: 565adf42059330fb821c57cb3b1200035fb866a1f2190ca87e093bc34de9edc0
4
+ data.tar.gz: 89db4dd987f39d4b77acd52121aa31bef54858f81d614557ac7b9984a1066ba0
5
5
  SHA512:
6
- metadata.gz: 6df9bcdd91aa229039baad046a06d471ae11367e78a594f655abffd808c3f9b14507f9fca32d99c4f3fbd13a3ab2743451826238bc8e55e9ddc0161546276bae
7
- data.tar.gz: 53b8fd83915c5ee898237dc113099a7ea0e339961dac83b11d4b16285a1f2fb3b4581d1876f70c2a78608556b22760da3ba8fff7146af0b66c701d12e963005b
6
+ metadata.gz: e3bbb8988d833e3c6e6ce4cd13da64111e2ee15b400c2eb34b78bca9f51ae80525336baf923ba18cdbf85b5320d542f8050567792e7d5a437b28e2d09d29729d
7
+ data.tar.gz: ae508f3bdb44a35b8edd0c89ebcc53a64d3f7340214ded6c8dd47d0c757c66f5f99700f466c86278e8ee0a38ab838b8509c80ba8af2f253653f967b91f52a814
data/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
 
7
+ ## [1.5.9] - 2026-08-14
8
+
9
+ ### Added
10
+ - OrcaRouter as a named provider preset (#480 - @Marc-oss-hub)
11
+ - DeepSeek peak/off-peak billing support
12
+ - Unicode homoglyph-tolerant string matching in edit mode
13
+ - Load-more pagination in extension marketplace
14
+ - Remove-from-project action in session menu
15
+ - Move-to-project submenu in session actions menu
16
+ - Changelog link in version badge and upgrade popover
17
+ - ext-studio scaffolding generates a runnable test/handler_test.rb and promotes "write tests" to its own step
18
+ - Lucide SVG icons for cron and extension group icons
19
+
20
+ ### Improved
21
+ - Skill "use" button themed with accent color
22
+ - Reordered provider presets list
23
+ - Replaced native confirm() with a Modal component and refreshed aside icons
24
+ - Billing sidebar icon switched to circle-dollar-sign
25
+ - Settings data tab renamed to backup management
26
+ - Refined project session group indentation and hover styling
27
+
28
+ ### Fixed
29
+ - Scroll to top when entering a session group sub-view
30
+ - Zhipu AI website_url now points to the console overview page
31
+ - WSL path translation and CRLF line-ending preservation (#454 - @shipinliang)
32
+ - Project sessions re-render on language switch
33
+ - Corrected arrow direction for move-in/move-out project icons
34
+ - Attachment drafts isolated per session (#478)
35
+ - Pinned sessions prioritized in project session lists
36
+ - Projects.all() returns a sorted list
37
+ - Builtin extension skills classified as system and hidden third-party ext skills from the panel
38
+ - Feishu WebSocket TLS SNI (#479 - @ayers-ltd)
39
+ - stop_reason mapping expanded from 3 to 8 cases (#469 - @shipinliang)
40
+
41
+ ### More
42
+ - Shortened ext-developer title
43
+ - cmdbar hover and session group indent tweaks
44
+
7
45
  ## [1.5.8] - 2026-08-12
8
46
 
9
47
  ### Added
data/README.md CHANGED
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provider).
196
196
 
197
- Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
197
+ Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — or any custom endpoint.
198
198
 
199
199
  ## Coding use case
200
200
 
data/README_CN.md CHANGED
@@ -189,7 +189,7 @@ $ openclacky
189
189
 
190
190
  设置你的 **API Key**、**模型**和 **Base URL**(任意 OpenAI 兼容提供商)。
191
191
 
192
- 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter**,或任意自定义端点。
192
+ 开箱即支持:**Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter**,或任意自定义端点。
193
193
 
194
194
  ## 代码开发场景
195
195
 
data/README_JA.md CHANGED
@@ -194,7 +194,7 @@ $ openclacky
194
194
 
195
195
  **API Key**、**Model**、**Base URL**(任意の OpenAI 互換プロバイダー)を設定します。
196
196
 
197
- 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — または任意のカスタムエンドポイント。
197
+ 標準でサポート: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter · OrcaRouter** — または任意のカスタムエンドポイント。
198
198
 
199
199
  ## コーディングのユースケース
200
200
 
@@ -777,21 +777,26 @@ module Clacky
777
777
  end
778
778
 
779
779
  # Search the public extension marketplace. Anonymous — no license required.
780
- # Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
781
- def search_extensions!(query: nil, sort: nil)
780
+ # Uses GET /api/v1/extensions. Returns { success:, extensions: [], meta:, error: }.
781
+ # `meta` carries the platform's pagination info ({ current_page, total_pages,
782
+ # total_count, per_page }) when available.
783
+ def search_extensions!(query: nil, sort: nil, page: nil, per_page: nil)
782
784
  params = {}
783
- params["q"] = query if query && !query.to_s.strip.empty?
784
- params["sort"] = sort if sort && !sort.to_s.strip.empty?
785
+ params["q"] = query if query && !query.to_s.strip.empty?
786
+ params["sort"] = sort if sort && !sort.to_s.strip.empty?
787
+ params["page"] = page if page && page.to_i > 1
788
+ params["per_page"] = per_page if per_page && per_page.to_i > 0
785
789
  qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
786
790
  response = platform_client.get("/api/v1/extensions#{qs}")
787
791
 
788
792
  if response[:success]
789
- { success: true, extensions: response[:data]["extensions"] || [] }
793
+ data = response[:data] || {}
794
+ { success: true, extensions: data["extensions"] || [], meta: data["meta"] }
790
795
  else
791
- { success: false, error: response[:error] || "Search failed", extensions: [] }
796
+ { success: false, error: response[:error] || "Search failed", extensions: [], meta: nil }
792
797
  end
793
798
  rescue StandardError => e
794
- { success: false, error: "Network error: #{e.message}", extensions: [] }
799
+ { success: false, error: "Network error: #{e.message}", extensions: [], meta: nil }
795
800
  end
796
801
 
797
802
  # Fetch a single public marketplace extension's detail (contributes +
@@ -17,7 +17,7 @@ contributes:
17
17
  order: 210
18
18
  agents:
19
19
  - id: ext-developer
20
- title: Extension Developer
20
+ title: Ext Developer
21
21
  title_zh: 扩展开发
22
22
  description: AI expert that helps you build custom AI agent workflows
23
23
  description_zh: 帮你开发自定义 AI Agent 工作流的专家
@@ -1372,7 +1372,7 @@
1372
1372
  const unpubBtn = el("button", { class: "studio-btn studio-btn-danger studio-btn-sm", text: t("btn.unpublish") });
1373
1373
  unpubBtn.style.marginLeft = "auto";
1374
1374
  unpubBtn.addEventListener("click", async () => {
1375
- if (!window.confirm(t("published.confirm", { id: e.id }))) return;
1375
+ if (!await Clacky.Modal.confirm(t("published.confirm", { id: e.id }))) return;
1376
1376
  unpubBtn.disabled = true;
1377
1377
  try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
1378
1378
  catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
@@ -1525,7 +1525,7 @@
1525
1525
  actions.appendChild(el("button", { class: "studio-btn studio-btn-primary", text: t("extlist.btn.iterate"), onclick: () => createExtension(t("extlist.iterate.seed", { id: ext.id })) }));
1526
1526
  const un = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.unpublish") });
1527
1527
  un.addEventListener("click", async () => {
1528
- if (!window.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1528
+ if (!await Clacky.Modal.confirm(t("extlist.unpublish.confirm", { id: ext.id }))) return;
1529
1529
  un.disabled = true;
1530
1530
  try { await postJson("/unpublish", { ext_id: ext.id }); await reload(); rebuild(); }
1531
1531
  catch (e) { un.disabled = false; alert(t("err.generic", { msg: e.message })); }
@@ -1599,7 +1599,7 @@
1599
1599
  }
1600
1600
 
1601
1601
  async function doDelete(ext, btn) {
1602
- if (!confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1602
+ if (!await Clacky.Modal.confirm(t("extlist.delete.confirm", { id: ext.id }))) return;
1603
1603
  btn.disabled = true;
1604
1604
  try {
1605
1605
  const res = await fetch("/api/ext/ext-studio/local", {
@@ -1766,7 +1766,7 @@
1766
1766
  try {
1767
1767
  let result = await publish(name, isUpdate);
1768
1768
  if (!result.ok && result.already_exists && !isUpdate) {
1769
- if (window.confirm(`"${name}" already exists on the platform. Overwrite?`)) {
1769
+ if (await Clacky.Modal.confirm(`"${name}" already exists on the platform. Overwrite?`)) {
1770
1770
  result = await publish(name, true);
1771
1771
  } else {
1772
1772
  btn.disabled = false;
@@ -280,14 +280,15 @@ This creates `~/.clacky/ext/local/<id>/` with a working hello panel + handler:
280
280
  - `ext.yml` — the manifest
281
281
  - `panels/hello/view.js` — a panel that pings the backend
282
282
  - `api/handler.rb` — a `Clacky::ApiExtension` subclass mounted at `/api/ext/<id>/`
283
+ - `test/handler_test.rb` — a runnable minitest example (`ruby test/handler_test.rb`)
283
284
 
284
285
  Use `--full` only when the user needs the kitchen-sink reference exercising all seven
285
286
  contributes types — it's a lot to read, so prefer the plain scaffold otherwise.
286
287
 
287
288
  ### 3 — Read what was generated
288
289
 
289
- Always read the generated `ext.yml`, `view.js`, and `handler.rb` before editing. This
290
- is your starting point; you'll reshape it to match the idea.
290
+ Always read the generated `ext.yml`, `view.js`, `handler.rb`, and `test/handler_test.rb`
291
+ before editing. This is your starting point; you'll reshape it to match the idea.
291
292
 
292
293
  ### 4 — Reshape to the idea
293
294
 
@@ -366,7 +367,22 @@ Rules while reshaping:
366
367
  reference `panels: [id]` and `skills: [id]`. Add those blocks to `ext.yml` only if the
367
368
  idea needs them.
368
369
 
369
- ### 5 — Confirm it loads
370
+ ### 5 — Write tests
371
+
372
+ Test the backend before you call it done. Rewrite the scaffolded `test/handler_test.rb`
373
+ to exercise your real routes (parse the JSON response, assert its fields), then run it
374
+ green:
375
+
376
+ ```
377
+ ruby test/handler_test.rb
378
+ ```
379
+
380
+ - If `test/` is missing from the scaffold, create `test/handler_test.rb` yourself
381
+ (`mkdir -p test` then write it) — do NOT skip tests just because the file is absent.
382
+ - A handler that only returns a static string may keep the scaffold's hello test;
383
+ anything that fetches, transforms, caches, or computes MUST have a real test.
384
+
385
+ ### 6 — Confirm it loads
370
386
 
371
387
  Run `clacky ext verify` and confirm the new units resolve with no errors, then have the
372
388
  user reload the WebUI page. If verify reports problems, go to **Debug & verify**.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run from the container root:
4
+ # ruby test/handler_test.rb
5
+ require "minitest/autorun"
6
+ require "clacky"
7
+ require "json"
8
+
9
+ load File.expand_path("../api/handler.rb", __dir__)
10
+
11
+ class <%= const_prefix %>ExtTest < Minitest::Test
12
+ def test_root_route_returns_json
13
+ route = <%= const_prefix %>Ext.routes.find { |r| r.method == :get && r.pattern == "/" }
14
+ refute_nil route, "expected a GET / route"
15
+
16
+ handler = <%= const_prefix %>Ext.new(req: nil, res: nil, route: route, params: {}, http_server: nil)
17
+ halt = assert_raises(Clacky::ApiExtension::Halt) { handler.invoke }
18
+
19
+ assert_equal 200, halt.status
20
+ assert_equal "hello from <%= slug %>", JSON.parse(halt.payload)["message"]
21
+ end
22
+ end
@@ -181,10 +181,27 @@ module Clacky
181
181
  { id: tc["id"], type: "function", name: tc["name"], arguments: args }
182
182
  end
183
183
 
184
+ # Map Anthropic stop_reason → OpenAI-style finish_reason so downstream
185
+ # (llm_caller empty-response detector, agent loop, CostTracker) stays
186
+ # provider-agnostic. Each cluster shares the same semantic class:
187
+ # stop — natural end of turn (end_turn / pause_turn / stop_sequence)
188
+ # length — token or context-window limit hit (max_tokens /
189
+ # model_context_window_exceeded; mapping the latter to
190
+ # "length" also prevents a futile retry in llm_caller's
191
+ # empty-response detector, which exempts "length")
192
+ # content_filter — model declined (refusal)
193
+ # other — context compaction (compaction)
194
+ # Note: `compaction` is a real stop_reason but only emitted under the
195
+ # `compact-2026-01-12` beta header (server-side context compaction),
196
+ # so it is not yet in the stable SDK's StopReason literal list.
197
+ # See: https://platform.claude.com/docs/en/build-with-claude/compaction
198
+ # Unmapped values fall through unchanged.
184
199
  finish_reason = case data["stop_reason"]
185
- when "end_turn" then "stop"
200
+ when "end_turn", "pause_turn", "stop_sequence" then "stop"
186
201
  when "tool_use" then "tool_calls"
187
- when "max_tokens" then "length"
202
+ when "max_tokens", "model_context_window_exceeded" then "length"
203
+ when "refusal" then "content_filter"
204
+ when "compaction" then "other"
188
205
  else data["stop_reason"]
189
206
  end
190
207
 
@@ -118,11 +118,16 @@ module Clacky
118
118
  { id: tc["toolUseId"], type: "function", name: tc["name"], arguments: args }
119
119
  end
120
120
 
121
- # Map Bedrock stopReason → canonical finish_reason
121
+ # Map Bedrock stopReason → canonical finish_reason (same clusters as
122
+ # the Anthropic direct adapter — see lib/clacky/message_format/anthropic.rb).
122
123
  finish_reason = case data["stopReason"]
123
- when "end_turn" then "stop"
124
+ when "end_turn", "pause_turn", "stop_sequence" then "stop"
124
125
  when "tool_use" then "tool_calls"
125
- when "max_tokens" then "length"
126
+ when "max_tokens", "model_context_window_exceeded" then "length"
127
+ when "refusal" then "content_filter"
128
+ # `compaction` is a beta stop_reason (compact-2026-01-12);
129
+ # see anthropic.rb for the full note.
130
+ when "compaction" then "other"
126
131
  else data["stopReason"]
127
132
  end
128
133
 
@@ -253,32 +253,34 @@ module Clacky
253
253
  "website_url" => "https://platform.deepseek.com/api_keys"
254
254
  }.freeze,
255
255
 
256
- "minimax" => {
257
- "name" => "Minimax",
258
- "base_url" => "https://api.minimaxi.com/v1",
256
+ "glm" => {
257
+ "name" => "GLM (Z.ai / Zhipu)",
258
+ "base_url" => "https://open.bigmodel.cn/api/paas/v4",
259
259
  "api" => "openai-completions",
260
- "default_model" => "MiniMax-M3",
261
- "models" => ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
262
- # MiniMax operates two regional endpoints with identical APIs & model
263
- # lineup mainland China (.com) and international (.io). Listing both
264
- # lets find_by_base_url identify either one as provider "minimax",
265
- # so capability checks (vision=false) fire correctly regardless of
266
- # which endpoint the user configured.
260
+ "default_model" => "glm-5.2",
261
+ "models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
262
+ # Zhipu / Z.ai expose four functionally-equivalent endpoints:
263
+ # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
264
+ # each with a general-billing and a Coding-Plan subpath. They share the
265
+ # same model lineup & identical capability profile, so a single preset
266
+ # with endpoint_variants is the right shape — one source of truth for
267
+ # vision/model_capabilities, four URLs recognised by find_by_base_url.
268
+ # Without this, users pointing at api.z.ai or the /coding/ path fell
269
+ # through to the conservative "assume vision=true" default and got
270
+ # hallucinated image descriptions on text-only GLM models (C-5563).
267
271
  "endpoint_variants" => [
268
- { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
269
- { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
272
+ { "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
273
+ { "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
274
+ { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
275
+ { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
270
276
  ].freeze,
271
- # MiniMax M2.5/M2.7 are text-only on this endpoint. M3 (released 2026-06-01)
272
- # is natively multimodal — it accepts image input via OpenAI-style
273
- # image_url content parts — so it overrides the provider-level
274
- # vision=false below. M3 exposes a 1,000,000-token context window
275
- # (M2.7: 204,800; M2.5: 40,960).
277
+ # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
276
278
  "capabilities" => { "vision" => false }.freeze,
277
279
  "model_capabilities" => {
278
- "MiniMax-M3" => { "vision" => true }.freeze
280
+ "glm-5v-turbo" => { "vision" => true }.freeze
279
281
  }.freeze,
280
- "default_ocr_model" => "MiniMax-M3",
281
- "website_url" => "https://platform.minimax.io/"
282
+ "default_ocr_model" => "glm-5v-turbo",
283
+ "website_url" => "https://open.bigmodel.cn/console/overview"
282
284
  }.freeze,
283
285
 
284
286
  "kimi" => {
@@ -346,6 +348,34 @@ module Clacky
346
348
  "website_url" => "https://www.kimi.com/code"
347
349
  }.freeze,
348
350
 
351
+ "minimax" => {
352
+ "name" => "Minimax",
353
+ "base_url" => "https://api.minimaxi.com/v1",
354
+ "api" => "openai-completions",
355
+ "default_model" => "MiniMax-M3",
356
+ "models" => ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"],
357
+ # MiniMax operates two regional endpoints with identical APIs & model
358
+ # lineup — mainland China (.com) and international (.io). Listing both
359
+ # lets find_by_base_url identify either one as provider "minimax",
360
+ # so capability checks (vision=false) fire correctly regardless of
361
+ # which endpoint the user configured.
362
+ "endpoint_variants" => [
363
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
364
+ { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
365
+ ].freeze,
366
+ # MiniMax M2.5/M2.7 are text-only on this endpoint. M3 (released 2026-06-01)
367
+ # is natively multimodal — it accepts image input via OpenAI-style
368
+ # image_url content parts — so it overrides the provider-level
369
+ # vision=false below. M3 exposes a 1,000,000-token context window
370
+ # (M2.7: 204,800; M2.5: 40,960).
371
+ "capabilities" => { "vision" => false }.freeze,
372
+ "model_capabilities" => {
373
+ "MiniMax-M3" => { "vision" => true }.freeze
374
+ }.freeze,
375
+ "default_ocr_model" => "MiniMax-M3",
376
+ "website_url" => "https://platform.minimax.io/"
377
+ }.freeze,
378
+
349
379
  "anthropic" => {
350
380
  "name" => "Anthropic (Claude)",
351
381
  "base_url" => "https://api.anthropic.com",
@@ -356,6 +386,70 @@ module Clacky
356
386
  "website_url" => "https://console.anthropic.com/settings/keys"
357
387
  }.freeze,
358
388
 
389
+ "openai" => {
390
+ "name" => "OpenAI (GPT)",
391
+ "base_url" => "https://api.openai.com/v1",
392
+ "api" => "openai-completions",
393
+ "default_model" => "gpt-5.5",
394
+ "models" => [
395
+ "gpt-5.5",
396
+ "gpt-5.4",
397
+ "gpt-5.4-mini",
398
+ "gpt-5.4-nano",
399
+ "o4-mini",
400
+ "o3"
401
+ ],
402
+ # GPT-5.x and o-series models are multimodal (text + image input).
403
+ "capabilities" => { "vision" => true }.freeze,
404
+ # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
405
+ # o4-mini and o3 are reasoning models without a lite-tier sibling here.
406
+ "lite_models" => {
407
+ "gpt-5.5" => "gpt-5.4-mini",
408
+ "gpt-5.4" => "gpt-5.4-mini"
409
+ },
410
+ # OpenAI's image generation model — same /v1/images/generations
411
+ # endpoint, so the OpenAICompat image provider handles it.
412
+ "image_models" => [
413
+ "gpt-image-2"
414
+ ],
415
+ "default_image_model" => "gpt-image-2",
416
+ "default_ocr_model" => "gpt-5.4-mini",
417
+ "website_url" => "https://platform.openai.com/api-keys"
418
+ }.freeze,
419
+
420
+ "qwen" => {
421
+ "name" => "Qwen (Alibaba)",
422
+ "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
423
+ "api" => "openai-completions",
424
+ "default_model" => "qwen3.7-max",
425
+ "models" => [
426
+ "qwen3.7-max",
427
+ "qwen3.6-plus",
428
+ "qwen3.6-max",
429
+ "qwen3.6-27b",
430
+ "qwen3.6-flash",
431
+ "qwen-plus-latest",
432
+ ],
433
+ "endpoint_variants" => [
434
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
435
+ { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
436
+ { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
437
+ ].freeze,
438
+ "capabilities" => { "vision" => true }.freeze,
439
+ "model_capabilities" => {
440
+ "qwen3.7-max" => { "vision" => false }.freeze
441
+ }.freeze,
442
+ "default_ocr_model" => "qwen3.6-flash",
443
+ "lite_models" => {
444
+ "qwen3.7-max" => "qwen3.6-flash",
445
+ "qwen3.6-plus" => "qwen3.6-flash",
446
+ "qwen3.6-max" => "qwen3.6-flash",
447
+ "qwen3.6-27b" => "qwen3.6-flash",
448
+ "qwen-plus-latest" => "qwen3.6-flash"
449
+ },
450
+ "website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
451
+ }.freeze,
452
+
359
453
  "mimo" => {
360
454
  "name" => "MiMo (Xiaomi)",
361
455
  "base_url" => "https://api.xiaomimimo.com/v1",
@@ -388,36 +482,6 @@ module Clacky
388
482
  "website_url" => "https://platform.xiaomimimo.com/"
389
483
  }.freeze,
390
484
 
391
- "glm" => {
392
- "name" => "GLM (Z.ai / Zhipu)",
393
- "base_url" => "https://open.bigmodel.cn/api/paas/v4",
394
- "api" => "openai-completions",
395
- "default_model" => "glm-5.2",
396
- "models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
397
- # Zhipu / Z.ai expose four functionally-equivalent endpoints:
398
- # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
399
- # each with a general-billing and a Coding-Plan subpath. They share the
400
- # same model lineup & identical capability profile, so a single preset
401
- # with endpoint_variants is the right shape — one source of truth for
402
- # vision/model_capabilities, four URLs recognised by find_by_base_url.
403
- # Without this, users pointing at api.z.ai or the /coding/ path fell
404
- # through to the conservative "assume vision=true" default and got
405
- # hallucinated image descriptions on text-only GLM models (C-5563).
406
- "endpoint_variants" => [
407
- { "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
408
- { "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
409
- { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
410
- { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
411
- ].freeze,
412
- # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
413
- "capabilities" => { "vision" => false }.freeze,
414
- "model_capabilities" => {
415
- "glm-5v-turbo" => { "vision" => true }.freeze
416
- }.freeze,
417
- "default_ocr_model" => "glm-5v-turbo",
418
- "website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
419
- }.freeze,
420
-
421
485
  # Volcengine Ark (Doubao) — ByteDance's model platform, OpenAI-compatible.
422
486
  # Exposes three functionally-equivalent endpoints (Pay-as-you-go / Coding
423
487
  # Plan / Agent Plan) that share the same model lineup and capability
@@ -540,68 +604,57 @@ module Clacky
540
604
  "website_url" => "https://ollama.com/settings/keys"
541
605
  }.freeze,
542
606
 
543
- "openai" => {
544
- "name" => "OpenAI (GPT)",
545
- "base_url" => "https://api.openai.com/v1",
607
+ "orcarouter" => {
608
+ "name" => "OrcaRouter",
609
+ "base_url" => "https://api.orcarouter.ai/v1",
546
610
  "api" => "openai-completions",
547
- "default_model" => "gpt-5.5",
611
+ "default_model" => "openai/gpt-5.5",
612
+ # Curated default lineup. OrcaRouter exposes 190+ models from OpenAI,
613
+ # Anthropic, Google, DeepSeek, Qwen, MiniMax, Zhipu and others behind a
614
+ # single OpenAI-compatible endpoint. Shipping a small list of the
615
+ # mainstream Claude + GPT entries gives users a working dropdown out
616
+ # of the box; users can still type any other OrcaRouter model id
617
+ # manually (e.g. "orcarouter/auto" for request-level auto-routing).
548
618
  "models" => [
549
- "gpt-5.5",
550
- "gpt-5.4",
551
- "gpt-5.4-mini",
552
- "gpt-5.4-nano",
553
- "o4-mini",
554
- "o3"
619
+ "anthropic/claude-sonnet-5",
620
+ "anthropic/claude-opus-4.8",
621
+ "anthropic/claude-haiku-4.5",
622
+ "openai/gpt-5.5",
623
+ "openai/gpt-5.4",
624
+ "openai/gpt-5.4-mini",
625
+ "google/gemini-3.5-flash",
626
+ "deepseek/deepseek-v4-flash",
627
+ "z-ai/glm-5.2",
628
+ "orcarouter/auto"
555
629
  ],
556
- # GPT-5.x and o-series models are multimodal (text + image input).
557
- "capabilities" => { "vision" => true }.freeze,
558
- # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
559
- # o4-mini and o3 are reasoning models without a lite-tier sibling here.
630
+ # Per-primary lite pairing Claude family pairs with Haiku, GPT
631
+ # family pairs with the mini variant. Mirrors the openrouter preset
632
+ # so subagents on OrcaRouter get a sensible cheap/fast sidekick.
560
633
  "lite_models" => {
561
- "gpt-5.5" => "gpt-5.4-mini",
562
- "gpt-5.4" => "gpt-5.4-mini"
634
+ "anthropic/claude-sonnet-5" => "anthropic/claude-haiku-4.5",
635
+ "anthropic/claude-opus-4.8" => "anthropic/claude-haiku-4.5",
636
+ "openai/gpt-5.5" => "openai/gpt-5.4-mini",
637
+ "openai/gpt-5.4" => "openai/gpt-5.4-mini"
563
638
  },
564
- # OpenAI's image generation model same /v1/images/generations
565
- # endpoint, so the OpenAICompat image provider handles it.
566
- "image_models" => [
567
- "gpt-image-2"
568
- ],
569
- "default_image_model" => "gpt-image-2",
570
- "default_ocr_model" => "gpt-5.4-mini",
571
- "website_url" => "https://platform.openai.com/api-keys"
572
- }.freeze,
573
-
574
- "qwen" => {
575
- "name" => "Qwen (Alibaba)",
576
- "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
577
- "api" => "openai-completions",
578
- "default_model" => "qwen3.7-max",
579
- "models" => [
580
- "qwen3.7-max",
581
- "qwen3.6-plus",
582
- "qwen3.6-max",
583
- "qwen3.6-27b",
584
- "qwen3.6-flash",
585
- "qwen-plus-latest",
586
- ],
587
- "endpoint_variants" => [
588
- { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
589
- { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
590
- { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
591
- ].freeze,
639
+ # Per-model API type overrides. OrcaRouter proxies Claude through a
640
+ # native Anthropic /v1/messages endpoint (https://api.orcarouter.ai/v1/messages)
641
+ # in addition to its OpenAI-compatible /chat/completions endpoint.
642
+ # Routing "anthropic/*" via the native endpoint preserves cache_control
643
+ # fidelity, matching what Claude Code CLI does internally (same rationale
644
+ # as the openrouter preset). Non-Claude models keep the OpenAI shim.
645
+ "model_api_overrides" => {
646
+ /\Aanthropic\// => "anthropic-messages"
647
+ }.freeze,
648
+ # Most models on OrcaRouter are vision-capable; DeepSeek / GLM-5.2 and
649
+ # the "orcarouter/auto" router are text-only.
592
650
  "capabilities" => { "vision" => true }.freeze,
593
651
  "model_capabilities" => {
594
- "qwen3.7-max" => { "vision" => false }.freeze
652
+ "deepseek/deepseek-v4-flash" => { "vision" => false }.freeze,
653
+ "z-ai/glm-5.2" => { "vision" => false }.freeze,
654
+ "orcarouter/auto" => { "vision" => false }.freeze
595
655
  }.freeze,
596
- "default_ocr_model" => "qwen3.6-flash",
597
- "lite_models" => {
598
- "qwen3.7-max" => "qwen3.6-flash",
599
- "qwen3.6-plus" => "qwen3.6-flash",
600
- "qwen3.6-max" => "qwen3.6-flash",
601
- "qwen3.6-27b" => "qwen3.6-flash",
602
- "qwen-plus-latest" => "qwen3.6-flash"
603
- },
604
- "website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
656
+ "default_ocr_model" => "google/gemini-3.5-flash",
657
+ "website_url" => "https://www.orcarouter.ai"
605
658
  }.freeze
606
659
 
607
660
  }.freeze
@@ -71,6 +71,12 @@ module Clacky
71
71
  ssl_context = OpenSSL::SSL::SSLContext.new
72
72
  ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
73
73
  ssl = OpenSSL::SSL::SSLSocket.new(tcp, ssl_context)
74
+ # Feishu/Lark WebSocket endpoints are served behind CDN edges that
75
+ # require SNI to select the correct certificate/backend. Some
76
+ # Ruby/OpenSSL combinations do not infer the hostname for manually
77
+ # created SSLSocket instances, which can make the peer abort the TLS
78
+ # handshake with `tlsv1 alert internal error`.
79
+ ssl.hostname = uri.host if ssl.respond_to?(:hostname=)
74
80
  ssl.sync_close = true
75
81
  ssl.connect
76
82
  ssl