openclacky 1.5.7 → 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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +3 -3
  4. data/README_CN.md +2 -2
  5. data/README_JA.md +2 -2
  6. data/lib/clacky/agent.rb +2 -3
  7. data/lib/clacky/agent_config.rb +40 -18
  8. data/lib/clacky/brand_config.rb +12 -7
  9. data/lib/clacky/cli.rb +0 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +27 -14
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +22 -5
  13. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  14. data/lib/clacky/extension/api_loader.rb +4 -11
  15. data/lib/clacky/extension/packager.rb +1 -1
  16. data/lib/clacky/extension/scaffold/templates/hello/test/handler_test.rb.erb +22 -0
  17. data/lib/clacky/extension/verifier.rb +1 -1
  18. data/lib/clacky/media/generator.rb +3 -3
  19. data/lib/clacky/message_format/anthropic.rb +19 -2
  20. data/lib/clacky/message_format/bedrock.rb +8 -3
  21. data/lib/clacky/message_history.rb +1 -1
  22. data/lib/clacky/providers.rb +159 -105
  23. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
  24. data/lib/clacky/server/http_server.rb +67 -79
  25. data/lib/clacky/session_manager.rb +7 -0
  26. data/lib/clacky/skill_loader.rb +4 -0
  27. data/lib/clacky/tools/base.rb +11 -1
  28. data/lib/clacky/tools/edit.rb +28 -0
  29. data/lib/clacky/tools/terminal.rb +5 -0
  30. data/lib/clacky/tools/trash_manager.rb +1 -1
  31. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  32. data/lib/clacky/ui2/components/input_area.rb +1 -0
  33. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  34. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  35. data/lib/clacky/ui2/line_editor.rb +1 -0
  36. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  37. data/lib/clacky/ui2/ui_controller.rb +1 -0
  38. data/lib/clacky/utils/model_pricing.rb +62 -27
  39. data/lib/clacky/utils/string_matcher.rb +29 -2
  40. data/lib/clacky/version.rb +1 -1
  41. data/lib/clacky/web/app.css +278 -221
  42. data/lib/clacky/web/components/chat-navigator.js +1 -1
  43. data/lib/clacky/web/features/billing/store.js +3 -3
  44. data/lib/clacky/web/features/billing/view.js +5 -61
  45. data/lib/clacky/web/features/extensions/store.js +65 -41
  46. data/lib/clacky/web/features/extensions/view.js +74 -10
  47. data/lib/clacky/web/features/new-session/view.js +24 -0
  48. data/lib/clacky/web/features/version/view.js +2 -2
  49. data/lib/clacky/web/i18n.js +36 -22
  50. data/lib/clacky/web/index.html +17 -17
  51. data/lib/clacky/web/projects.js +83 -285
  52. data/lib/clacky/web/sessions.js +151 -17
  53. data/lib/clacky/web/settings.js +6 -1
  54. metadata +2 -1
@@ -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
 
@@ -286,7 +286,7 @@ module Clacky
286
286
  private def estimate_content_tokens(content)
287
287
  case content
288
288
  when String
289
- ascii_chars = content.scan(/[ -~]/).length
289
+ ascii_chars = content.count(" -~")
290
290
  multibyte_chars = content.length - ascii_chars
291
291
  ((ascii_chars / 4.0) + (multibyte_chars / 1.5)).ceil
292
292
  when Array
@@ -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
@@ -472,7 +536,8 @@ module Clacky
472
536
  "model_capabilities" => {
473
537
  "glm-5.2" => { "vision" => false }.freeze,
474
538
  "deepseek-v4-pro" => { "vision" => false }.freeze,
475
- "deepseek-v4-flash" => { "vision" => false }.freeze
539
+ "deepseek-v4-flash" => { "vision" => false }.freeze,
540
+ "minimax-m2.7" => { "vision" => false }.freeze
476
541
  }.freeze,
477
542
  "default_ocr_model" => "doubao-seed-2.0-lite",
478
543
  "website_url" => "https://console.volcengine.com/ark/region:cn-beijing/overview"
@@ -539,68 +604,57 @@ module Clacky
539
604
  "website_url" => "https://ollama.com/settings/keys"
540
605
  }.freeze,
541
606
 
542
- "openai" => {
543
- "name" => "OpenAI (GPT)",
544
- "base_url" => "https://api.openai.com/v1",
607
+ "orcarouter" => {
608
+ "name" => "OrcaRouter",
609
+ "base_url" => "https://api.orcarouter.ai/v1",
545
610
  "api" => "openai-completions",
546
- "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).
547
618
  "models" => [
548
- "gpt-5.5",
549
- "gpt-5.4",
550
- "gpt-5.4-mini",
551
- "gpt-5.4-nano",
552
- "o4-mini",
553
- "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"
554
629
  ],
555
- # GPT-5.x and o-series models are multimodal (text + image input).
556
- "capabilities" => { "vision" => true }.freeze,
557
- # Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
558
- # 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.
559
633
  "lite_models" => {
560
- "gpt-5.5" => "gpt-5.4-mini",
561
- "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"
562
638
  },
563
- # OpenAI's image generation model — same /v1/images/generations
564
- # endpoint, so the OpenAICompat image provider handles it.
565
- "image_models" => [
566
- "gpt-image-2"
567
- ],
568
- "default_image_model" => "gpt-image-2",
569
- "default_ocr_model" => "gpt-5.4-mini",
570
- "website_url" => "https://platform.openai.com/api-keys"
571
- }.freeze,
572
-
573
- "qwen" => {
574
- "name" => "Qwen (Alibaba)",
575
- "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
576
- "api" => "openai-completions",
577
- "default_model" => "qwen3.7-max",
578
- "models" => [
579
- "qwen3.7-max",
580
- "qwen3.6-plus",
581
- "qwen3.6-max",
582
- "qwen3.6-27b",
583
- "qwen3.6-flash",
584
- "qwen-plus-latest",
585
- ],
586
- "endpoint_variants" => [
587
- { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
588
- { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
589
- { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
590
- ].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.
591
650
  "capabilities" => { "vision" => true }.freeze,
592
651
  "model_capabilities" => {
593
- "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
594
655
  }.freeze,
595
- "default_ocr_model" => "qwen3.6-flash",
596
- "lite_models" => {
597
- "qwen3.7-max" => "qwen3.6-flash",
598
- "qwen3.6-plus" => "qwen3.6-flash",
599
- "qwen3.6-max" => "qwen3.6-flash",
600
- "qwen3.6-27b" => "qwen3.6-flash",
601
- "qwen-plus-latest" => "qwen3.6-flash"
602
- },
603
- "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"
604
658
  }.freeze
605
659
 
606
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
@@ -184,7 +184,7 @@ module Clacky
184
184
  - 将大目标拆解为可执行的小步骤
185
185
  MD
186
186
 
187
- def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, socket: nil, master_pid: nil)
187
+ def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false, sessions_dir: nil, projects_file: nil, socket: nil, master_pid: nil)
188
188
  @host = host
189
189
  @port = port
190
190
  @agent_config = agent_config
@@ -197,7 +197,7 @@ module Clacky
197
197
  @restart_script = File.expand_path($0)
198
198
  @restart_argv = ARGV.dup
199
199
  @session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
200
- @project_manager = Clacky::Server::ProjectManager.new
200
+ @project_manager = Clacky::Server::ProjectManager.new(projects_file: projects_file)
201
201
  @registry = SessionRegistry.new(
202
202
  session_manager: @session_manager,
203
203
  session_restorer: method(:build_session_from_data),
@@ -1840,6 +1840,9 @@ module Clacky
1840
1840
  requested_model: state["requested_model"],
1841
1841
  configured: state["configured"]
1842
1842
  }
1843
+ if entry && entry["base_url"] && state["source"] != "custom"
1844
+ out[t][:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
1845
+ end
1843
1846
  end
1844
1847
 
1845
1848
  # Surface what the current default model can offer, even when the
@@ -1949,50 +1952,78 @@ module Clacky
1949
1952
  end
1950
1953
 
1951
1954
  def api_update_media_config(kind, req, res)
1952
- body = parse_json_body(req) || {}
1955
+ update_sidecar_config(res, kind, parse_json_body(req) || {})
1956
+ end
1957
+
1958
+ private def update_sidecar_config(res, kind, body)
1953
1959
  source = body["source"].to_s
1954
1960
  unless %w[off auto custom].include?(source)
1955
1961
  return json_response(res, 422, { error: "invalid source" })
1956
1962
  end
1957
1963
 
1958
- @agent_config.models.reject! { |m| m["type"] == kind }
1964
+ existing = @agent_config.models.find { |m| m["type"] == kind }
1959
1965
 
1960
1966
  case source
1961
1967
  when "off"
1962
- @agent_config.models << {
1963
- "id" => SecureRandom.uuid,
1964
- "type" => kind,
1965
- "disabled" => true
1966
- }
1967
- when "auto"
1968
- override = body["model"].to_s.strip
1969
- unless override.empty?
1968
+ if existing
1969
+ existing["mode"] = "off"
1970
+ else
1970
1971
  @agent_config.models << {
1971
- "id" => SecureRandom.uuid,
1972
- "type" => kind,
1973
- "model" => override
1972
+ "id" => SecureRandom.uuid,
1973
+ "type" => kind,
1974
+ "mode" => "off"
1974
1975
  }
1975
1976
  end
1977
+ when "auto"
1978
+ override = body["model"].to_s.strip
1979
+ if existing
1980
+ existing.delete("disabled")
1981
+ existing["mode"] = "auto"
1982
+ existing["model"] = override unless override.empty?
1983
+ else
1984
+ unless override.empty?
1985
+ @agent_config.models << {
1986
+ "id" => SecureRandom.uuid,
1987
+ "type" => kind,
1988
+ "mode" => "auto",
1989
+ "model" => override
1990
+ }
1991
+ end
1992
+ end
1976
1993
  when "custom"
1977
1994
  model = body["model"].to_s.strip
1978
1995
  base_url = body["base_url"].to_s.strip
1979
1996
  api_key = body["api_key"].to_s
1980
- if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
1997
+ if api_key.empty? || api_key.include?("****")
1998
+ api_key = existing ? existing["api_key"].to_s : ""
1999
+ end
2000
+ if model.empty? || base_url.empty? || api_key.empty?
1981
2001
  return json_response(res, 422, { error: "model, base_url, api_key are required" })
1982
2002
  end
1983
2003
 
1984
- @agent_config.models << {
1985
- "id" => SecureRandom.uuid,
1986
- "model" => model,
1987
- "base_url" => base_url,
1988
- "api_key" => api_key,
1989
- "anthropic_format" => body["anthropic_format"] || false,
1990
- "type" => kind
1991
- }
2004
+ if existing
2005
+ existing["model"] = model
2006
+ existing["base_url"] = base_url
2007
+ existing["api_key"] = api_key
2008
+ existing["anthropic_format"] = body["anthropic_format"] || false
2009
+ existing["mode"] = "custom"
2010
+ existing.delete("disabled")
2011
+ else
2012
+ @agent_config.models << {
2013
+ "id" => SecureRandom.uuid,
2014
+ "model" => model,
2015
+ "base_url" => base_url,
2016
+ "api_key" => api_key,
2017
+ "anthropic_format" => body["anthropic_format"] || false,
2018
+ "type" => kind,
2019
+ "mode" => "custom"
2020
+ }
2021
+ end
1992
2022
  end
1993
2023
 
1994
2024
  @agent_config.save
1995
- json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
2025
+ state = kind == "ocr" ? @agent_config.ocr_state : @agent_config.media_state(kind)
2026
+ json_response(res, 200, { ok: true, state: state })
1996
2027
  rescue => e
1997
2028
  json_response(res, 422, { error: e.message })
1998
2029
  end
@@ -2016,6 +2047,9 @@ module Clacky
2016
2047
  configured: state["configured"],
2017
2048
  primary: state["primary"] || false
2018
2049
  }
2050
+ if entry && entry["base_url"] && state["source"] != "custom"
2051
+ out[:saved_custom] = { model: entry["model"], base_url: entry["base_url"] }
2052
+ end
2019
2053
 
2020
2054
  # Auto-mode preview: surface what the OCR sidecar *would* be if the
2021
2055
  # user flipped to "auto" — derived from the same provider as the
@@ -2037,58 +2071,8 @@ module Clacky
2037
2071
  # PATCH /api/config/ocr
2038
2072
  # Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
2039
2073
  # anthropic_format? }
2040
- # Mirrors api_update_media_config but for the single "ocr" type.
2041
2074
  def api_update_ocr_config(req, res)
2042
- body = parse_json_body(req) || {}
2043
- source = body["source"].to_s
2044
- unless %w[off auto custom].include?(source)
2045
- return json_response(res, 422, { error: "invalid source" })
2046
- end
2047
-
2048
- @agent_config.models.reject! { |m| m["type"] == "ocr" }
2049
-
2050
- case source
2051
- when "off"
2052
- @agent_config.models << {
2053
- "id" => SecureRandom.uuid,
2054
- "type" => "ocr",
2055
- "disabled" => true
2056
- }
2057
- when "auto"
2058
- override = body["model"].to_s.strip
2059
- unless override.empty?
2060
- @agent_config.models << {
2061
- "id" => SecureRandom.uuid,
2062
- "type" => "ocr",
2063
- "model" => override
2064
- }
2065
- end
2066
- when "custom"
2067
- model = body["model"].to_s.strip
2068
- base_url = body["base_url"].to_s.strip
2069
- api_key = body["api_key"].to_s
2070
- if api_key.include?("****")
2071
- existing = @agent_config.models.find { |m| m["type"] == "ocr" && m["api_key"] }
2072
- api_key = existing ? existing["api_key"].to_s : ""
2073
- end
2074
- if model.empty? || base_url.empty? || api_key.empty?
2075
- return json_response(res, 422, { error: "model, base_url, api_key are required" })
2076
- end
2077
-
2078
- @agent_config.models << {
2079
- "id" => SecureRandom.uuid,
2080
- "model" => model,
2081
- "base_url" => base_url,
2082
- "api_key" => api_key,
2083
- "anthropic_format" => body["anthropic_format"] || false,
2084
- "type" => "ocr"
2085
- }
2086
- end
2087
-
2088
- @agent_config.save
2089
- json_response(res, 200, { ok: true, state: @agent_config.ocr_state })
2090
- rescue => e
2091
- json_response(res, 422, { error: e.message })
2075
+ update_sidecar_config(res, "ocr", parse_json_body(req) || {})
2092
2076
  end
2093
2077
 
2094
2078
  # POST /api/config/ocr/test
@@ -2562,7 +2546,7 @@ module Clacky
2562
2546
  # the same public marketplace here.
2563
2547
  def api_store_extensions(req, res)
2564
2548
  brand = Clacky::BrandConfig.load
2565
- result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
2549
+ result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"], page: req.query["page"], per_page: req.query["per_page"])
2566
2550
 
2567
2551
  if result[:success]
2568
2552
  installed = installed_extension_containers
@@ -2574,7 +2558,7 @@ module Clacky
2574
2558
  "installed_version" => container&.dig(:version)
2575
2559
  )
2576
2560
  end
2577
- json_response(res, 200, { ok: true, extensions: extensions })
2561
+ json_response(res, 200, { ok: true, extensions: extensions, meta: result[:meta] })
2578
2562
  else
2579
2563
  json_response(res, 200, {
2580
2564
  ok: true,
@@ -4521,7 +4505,11 @@ module Clacky
4521
4505
  upload_meta = Clacky::BrandConfig.load_upload_meta
4522
4506
  shadowed = @skill_loader.shadowed_by_local
4523
4507
 
4524
- skills = @skill_loader.all_skills.reject(&:brand_skill).map do |skill|
4508
+ skills = @skill_loader.all_skills.reject(&:brand_skill).reject do |skill|
4509
+ # Third-party extension skills (installed/local) are managed from the
4510
+ # extension panel, not the skills panel.
4511
+ @skill_loader.loaded_from[skill.identifier] == :extension
4512
+ end.map do |skill|
4525
4513
  source = @skill_loader.loaded_from[skill.identifier]
4526
4514
  meta = upload_meta[skill.identifier] || {}
4527
4515