openclacky 1.5.2 → 1.5.4
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 +4 -4
- data/.dockerignore +13 -0
- data/CHANGELOG.md +50 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +13 -15
- data/lib/clacky/agent/session_serializer.rb +6 -0
- data/lib/clacky/agent.rb +13 -1
- data/lib/clacky/agent_config.rb +42 -11
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/extension/api_extension.rb +3 -2
- data/lib/clacky/message_format/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +92 -4
- data/lib/clacky/providers.rb +67 -14
- data/lib/clacky/server/http_server.rb +361 -36
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/session_manager.rb +5 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/utils/model_pricing.rb +77 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1053 -143
- data/lib/clacky/web/app.js +19 -5
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/billing/view.js +2 -1
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/new-session/view.js +2 -2
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +99 -4
- data/lib/clacky/web/index.html +70 -17
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +146 -7
- data/lib/clacky/web/ws-dispatcher.js +59 -1
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -383,16 +383,16 @@ module Clacky
|
|
|
383
383
|
|
|
384
384
|
# Returns true when a public distribution refresh is due.
|
|
385
385
|
#
|
|
386
|
-
# Refresh is needed
|
|
387
|
-
#
|
|
388
|
-
# distribution data via #heartbeat
|
|
386
|
+
# Refresh is needed for unactivated branded installs and for private
|
|
387
|
+
# platform sources that have not supplied their deployment brand yet.
|
|
388
|
+
# Activated licenses already get fresh distribution data via #heartbeat!.
|
|
389
389
|
#
|
|
390
390
|
# Rate limit: once every HEARTBEAT_INTERVAL (24h), measured from the last
|
|
391
391
|
# SUCCESSFUL refresh. A failed refresh does not advance the clock so we'll
|
|
392
392
|
# keep trying on subsequent startups / status polls.
|
|
393
393
|
def distribution_refresh_due?
|
|
394
|
-
return false unless branded?
|
|
395
394
|
return false if activated?
|
|
395
|
+
return false unless branded? || private_platform_source?
|
|
396
396
|
return true if @distribution_last_refreshed_at.nil?
|
|
397
397
|
|
|
398
398
|
elapsed = Time.now.utc - @distribution_last_refreshed_at
|
|
@@ -400,7 +400,8 @@ module Clacky
|
|
|
400
400
|
end
|
|
401
401
|
|
|
402
402
|
# Refresh public brand assets (logo, theme, homepage_url, support_*) for
|
|
403
|
-
# installs
|
|
403
|
+
# unactivated installs. Package installs use an exact package lookup;
|
|
404
|
+
# private platform sources without a local brand use the source default.
|
|
404
405
|
#
|
|
405
406
|
# Motivation: `install.sh --brand-name=X --command=X` only writes
|
|
406
407
|
# product_name + package_name to brand.yml. The rest of the distribution
|
|
@@ -409,32 +410,47 @@ module Clacky
|
|
|
409
410
|
# anonymous public lookup endpoint.
|
|
410
411
|
#
|
|
411
412
|
# Behaviour:
|
|
412
|
-
# * No-op
|
|
413
|
-
#
|
|
413
|
+
# * No-op when already activated, or when neither a package_name nor a
|
|
414
|
+
# private platform source is available.
|
|
414
415
|
# * On success: apply_distribution + save + stamp
|
|
415
416
|
# @distribution_last_refreshed_at.
|
|
417
|
+
# * When a block is provided, discard the response unless it still
|
|
418
|
+
# confirms that the platform source is current.
|
|
416
419
|
# * On failure: log and return without touching the timestamp (so we
|
|
417
420
|
# retry on next trigger).
|
|
418
421
|
#
|
|
419
422
|
# Returns { success: Boolean, message: String }.
|
|
420
423
|
def refresh_distribution!
|
|
421
|
-
|
|
424
|
+
source_brand_lookup = private_platform_source? &&
|
|
425
|
+
(@package_name.nil? || @package_name.strip.empty?)
|
|
426
|
+
unless branded? || source_brand_lookup
|
|
422
427
|
return { success: false, message: "Not branded" }
|
|
423
428
|
end
|
|
424
429
|
if activated?
|
|
425
430
|
return { success: false, message: "License activated — use heartbeat! instead" }
|
|
426
431
|
end
|
|
427
|
-
if @package_name.nil? || @package_name.strip.empty?
|
|
432
|
+
if !source_brand_lookup && (@package_name.nil? || @package_name.strip.empty?)
|
|
428
433
|
return { success: false, message: "package_name not configured" }
|
|
429
434
|
end
|
|
430
435
|
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
if source_brand_lookup
|
|
437
|
+
path = "/api/v1/distributions/lookup"
|
|
438
|
+
else
|
|
439
|
+
encoded_pkg = URI.encode_www_form_component(@package_name.strip)
|
|
440
|
+
path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}"
|
|
441
|
+
end
|
|
433
442
|
|
|
434
|
-
Clacky::Logger.info(
|
|
443
|
+
Clacky::Logger.info(
|
|
444
|
+
"[Brand] refresh_distribution! fetching — package_name=#{@package_name || "source-default"}"
|
|
445
|
+
)
|
|
435
446
|
response = platform_client.get(path)
|
|
436
447
|
|
|
437
448
|
if response[:success] && response[:data].is_a?(Hash) && response[:data]["distribution"].is_a?(Hash)
|
|
449
|
+
if block_given? && !yield
|
|
450
|
+
Clacky::Logger.info("[Brand] refresh_distribution! discarded — platform source changed")
|
|
451
|
+
return { success: false, message: "Platform source changed during refresh" }
|
|
452
|
+
end
|
|
453
|
+
|
|
438
454
|
apply_distribution(response[:data]["distribution"])
|
|
439
455
|
@distribution_last_refreshed_at = Time.now.utc
|
|
440
456
|
save
|
|
@@ -1762,6 +1778,10 @@ module Clacky
|
|
|
1762
1778
|
@homepage_url = dist["homepage_url"] if dist.key?("homepage_url")
|
|
1763
1779
|
end
|
|
1764
1780
|
|
|
1781
|
+
private def private_platform_source?
|
|
1782
|
+
!ENV["CLACKY_LICENSE_SERVER"].to_s.strip.empty?
|
|
1783
|
+
end
|
|
1784
|
+
|
|
1765
1785
|
# Download a remote URL to a local file path.
|
|
1766
1786
|
#
|
|
1767
1787
|
# Deprecated: this method now delegates to
|
data/lib/clacky/client.rb
CHANGED
|
@@ -337,6 +337,13 @@ module Clacky
|
|
|
337
337
|
# ── OpenAI request / response ─────────────────────────────────────────────
|
|
338
338
|
|
|
339
339
|
def send_openai_request(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, on_chunk: nil, capability_model: nil)
|
|
340
|
+
# Override max_tokens when the model declares a higher output ceiling
|
|
341
|
+
# in Providers::MODEL_MAX_OUTPUT. Without this, strong models (GLM-5.2,
|
|
342
|
+
# Kimi-K3, MiMo-V2.5) are throttled to the 16K global default.
|
|
343
|
+
model_for_limit = capability_model || model
|
|
344
|
+
model_limit = Providers.max_output_for(model_for_limit)
|
|
345
|
+
max_tokens = model_limit if model_limit
|
|
346
|
+
|
|
340
347
|
# Apply cache_control markers to messages when caching is enabled.
|
|
341
348
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
342
349
|
messages = apply_message_caching(messages) if caching_enabled
|
|
@@ -143,3 +143,18 @@ main product, and apply every rule below whenever you propose or write code:
|
|
|
143
143
|
Ruby and carry supply-chain risk.
|
|
144
144
|
- After editing `view.js`, `handler.rb`, or a `SKILL.md`, tell the user to reload the
|
|
145
145
|
WebUI page — hot reload is per-request, no restart needed.
|
|
146
|
+
- After you finish editing extension files, call this once at the very end to trigger
|
|
147
|
+
a reload button in the UI (if it doesn't appear, the user can manually refresh the browser):
|
|
148
|
+
```
|
|
149
|
+
curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/show_ext_refresh" \
|
|
150
|
+
-H "Content-Type: application/json" \
|
|
151
|
+
-d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
|
|
152
|
+
```
|
|
153
|
+
(`--noproxy '*'` prevents shell proxy env vars from silently intercepting this loopback call.)
|
|
154
|
+
- If the extension contributes a `session.aside` panel, also call this right after the above
|
|
155
|
+
to open the panel automatically:
|
|
156
|
+
```
|
|
157
|
+
curl -s --noproxy '*' -X POST "http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/ui/open_aside" \
|
|
158
|
+
-H "Content-Type: application/json" \
|
|
159
|
+
-d "{\"session_id\": \"${CLACKY_SESSION_ID}\"}"
|
|
160
|
+
```
|
|
@@ -201,11 +201,7 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
201
201
|
error!("extension not found: #{ext_id}", status: 404) unless container
|
|
202
202
|
|
|
203
203
|
readme_path = File.join(container[:dir], "README.md")
|
|
204
|
-
|
|
205
|
-
File.delete(readme_path) if File.exist?(readme_path)
|
|
206
|
-
else
|
|
207
|
-
File.write(readme_path, readme, encoding: "utf-8")
|
|
208
|
-
end
|
|
204
|
+
File.write(readme_path, readme, encoding: "utf-8")
|
|
209
205
|
end
|
|
210
206
|
|
|
211
207
|
json(ok: true, ext_id: ext_id, saved_to: published ? "platform" : "local")
|
|
@@ -362,7 +358,7 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
362
358
|
post "/develop" do
|
|
363
359
|
idea = presence(json_body["idea"])
|
|
364
360
|
name = idea ? "扩展开发: #{idea[0, 40]}" : "扩展开发"
|
|
365
|
-
sid = create_session(name: name, prompt: idea, profile: "ext-developer"
|
|
361
|
+
sid = create_session(name: name, prompt: idea, profile: "ext-developer")
|
|
366
362
|
json(ok: true, session_id: sid)
|
|
367
363
|
end
|
|
368
364
|
|
|
@@ -1539,7 +1539,7 @@
|
|
|
1539
1539
|
let data = {};
|
|
1540
1540
|
try { data = await res.json(); } catch (_e) {}
|
|
1541
1541
|
if (!res.ok) throw new Error(data?.error || `Error ${res.status}`);
|
|
1542
|
-
|
|
1542
|
+
location.reload();
|
|
1543
1543
|
} catch (e) {
|
|
1544
1544
|
alert(t("err.generic", { msg: e.message }));
|
|
1545
1545
|
btn.disabled = false;
|
|
@@ -1957,7 +1957,7 @@
|
|
|
1957
1957
|
.studio-form-status { font-size: 12px; color: var(--color-text-tertiary); }
|
|
1958
1958
|
.studio-btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
1959
1959
|
.studio-readme-overlay { align-items: center; justify-content: center; }
|
|
1960
|
-
.studio-readme-modal { width:
|
|
1960
|
+
.studio-readme-modal { max-width: 1280px; width: 98%; height: 90vh; max-height: 960px; border-radius: var(--radius-md, 8px); margin: 0; padding: 24px 28px 20px; display: flex; flex-direction: column; }
|
|
1961
1961
|
.studio-readme-modal .studio-modal-title { margin-bottom: 16px; flex-shrink: 0; }
|
|
1962
1962
|
.studio-readme-split { display: flex; gap: 14px; flex: 1; min-height: 0; margin-bottom: 18px; }
|
|
1963
1963
|
.studio-readme-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
@@ -1965,13 +1965,20 @@
|
|
|
1965
1965
|
.studio-readme-textarea { flex: 1; resize: none; font-family: monospace; font-size: 12.5px; line-height: 1.65; border-radius: var(--radius-sm); }
|
|
1966
1966
|
.studio-readme-preview { flex: 1; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 12px 16px; background: var(--color-bg-secondary); font-size: 13px; line-height: 1.75; }
|
|
1967
1967
|
.studio-readme-preview > *:first-child { margin-top: 0; }
|
|
1968
|
-
.studio-readme-preview img { max-width: 100%; height: auto; display: block; border-radius: var(--radius-sm); }
|
|
1969
1968
|
.studio-readme-screenshots { border-top: 1px solid var(--color-border-primary); padding-top: 14px; margin-bottom: 6px; }
|
|
1970
1969
|
.studio-readme-screenshots-row { display: flex; align-items: center; gap: 10px; }
|
|
1971
1970
|
.studio-readme-screenshots-hint { font-size: 11px; color: var(--color-text-muted); }
|
|
1972
1971
|
.studio-readme-card { margin-top: 8px; }
|
|
1973
1972
|
.studio-readme-card-preview { max-height: 200px; overflow-y: auto; font-size: 13px; }
|
|
1974
|
-
.extension-readme-body img { max-width: min(800px, 100%); height: auto; }
|
|
1973
|
+
.extension-readme-body img { max-width: min(800px, 100%); height: auto; display: inline; vertical-align: middle; }
|
|
1974
|
+
.studio-readme-preview img { max-width: 100%; height: auto; display: inline; vertical-align: middle; }
|
|
1975
|
+
.studio-readme-preview hr, .extension-readme-body hr { border: none; border-top: 1px solid var(--color-border-primary); margin: 1.25em 0; }
|
|
1976
|
+
.studio-readme-preview table, .extension-readme-body table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.8125rem; }
|
|
1977
|
+
.studio-readme-preview th, .studio-readme-preview td, .extension-readme-body th, .extension-readme-body td { border: 1px solid var(--color-border-primary); padding: 0.375em 0.75em; text-align: left; }
|
|
1978
|
+
.studio-readme-preview th, .extension-readme-body th { background: rgba(128,128,128,0.15); font-weight: 600; }
|
|
1979
|
+
.studio-readme-preview pre, .extension-readme-body pre { background: rgba(128,128,128,0.15); border-radius: 6px; padding: 0.75em 1em; overflow-x: auto; margin: 0.625em 0; }
|
|
1980
|
+
.studio-readme-preview code, .extension-readme-body code { font-family: monospace; font-size: 0.8125em; background: rgba(128,128,128,0.15); border-radius: 4px; padding: 0.15em 0.4em; }
|
|
1981
|
+
.studio-readme-preview pre code, .extension-readme-body pre code { background: none; padding: 0; }
|
|
1975
1982
|
.studio-link-btn { background: none; border: none; padding: 0; font-size: 12px; color: var(--color-accent-primary); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
1976
1983
|
.studio-link-btn:hover { color: var(--color-accent-hover); }
|
|
1977
1984
|
`;
|
|
@@ -204,6 +204,10 @@ Response helpers: `json` / `text(str)` / `send_data(bytes, content_type:, filena
|
|
|
204
204
|
- Drive sessions from the backend: `create_session(prompt:, profile:, …)`,
|
|
205
205
|
`submit_task(session_id, prompt)`, `dispatch_to_session(session_id, prompt)` (runs a
|
|
206
206
|
side task on a fork and returns its reply without touching the conversation).
|
|
207
|
+
- `create_session(hidden: true)` creates a session the openclacky session list hides
|
|
208
|
+
and the 200-session cleanup skips - use it for dedicated extension sessions you
|
|
209
|
+
manage yourself. Still reachable by `session_id` via `submit_task` /
|
|
210
|
+
`dispatch_to_session` / `registry.with_session`; forking resets `hidden` to false.
|
|
207
211
|
- Public (no-auth) endpoints: call `public_endpoint("/path")` in the class **and** set
|
|
208
212
|
`public: true` at ext.yml top level — both are required.
|
|
209
213
|
|
|
@@ -294,7 +298,7 @@ contributes:
|
|
|
294
298
|
classes; swap the body for the real UI, POST to your own route:
|
|
295
299
|
|
|
296
300
|
```js
|
|
297
|
-
Clacky.ext.ui.mount("session.aside", function (ctx) {
|
|
301
|
+
Clacky.ext.ui.mount("session.aside", function (container, ctx) {
|
|
298
302
|
var el = document.createElement("div");
|
|
299
303
|
el.style.padding = "16px";
|
|
300
304
|
var input = document.createElement("input");
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""xlsx_parser.py — extract an XLSX/XLS workbook to Markdown tables.
|
|
3
|
+
|
|
4
|
+
Usage: python3 xlsx_parser.py <file_path>
|
|
5
|
+
Output: stdout — Markdown tables (UTF-8), one section per non-empty sheet
|
|
6
|
+
stderr — error messages
|
|
7
|
+
exit 0 success / 1 failure / 2 dependency missing
|
|
8
|
+
|
|
9
|
+
Uses openpyxl in read_only mode: a streaming reader that keeps memory
|
|
10
|
+
constant regardless of sheet size, so multi-megabyte worksheets parse in
|
|
11
|
+
seconds instead of exhausting RAM/CPU like a full DOM parse would.
|
|
12
|
+
"""
|
|
13
|
+
# VERSION: 2
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_table(rows):
|
|
18
|
+
width = max(len(r) for r in rows)
|
|
19
|
+
lines = []
|
|
20
|
+
for i, r in enumerate(rows):
|
|
21
|
+
padded = r + [""] * (width - len(r))
|
|
22
|
+
lines.append("| " + " | ".join(padded) + " |")
|
|
23
|
+
if i == 0:
|
|
24
|
+
lines.append("|" + " --- |" * width)
|
|
25
|
+
return "\n".join(lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) < 2:
|
|
30
|
+
sys.stderr.write("Usage: xlsx_parser.py <file_path>\n")
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
path = sys.argv[1]
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from openpyxl import load_workbook
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
sys.stderr.write(f"openpyxl not installed: {e}\n")
|
|
38
|
+
sys.stderr.write("Install with: pip3 install --user openpyxl\n")
|
|
39
|
+
sys.exit(2)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
wb = load_workbook(path, read_only=True, data_only=True)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
sys.stderr.write(f"Failed to open workbook: {e}\n")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
|
|
47
|
+
sections = []
|
|
48
|
+
for ws in wb.worksheets:
|
|
49
|
+
rows = []
|
|
50
|
+
for row in ws.iter_rows(values_only=True):
|
|
51
|
+
cells = ["" if v is None else str(v) for v in row]
|
|
52
|
+
if any(c != "" for c in cells):
|
|
53
|
+
rows.append(cells)
|
|
54
|
+
if rows:
|
|
55
|
+
sections.append(f"### {ws.title}\n\n{build_table(rows)}")
|
|
56
|
+
wb.close()
|
|
57
|
+
|
|
58
|
+
if not sections:
|
|
59
|
+
sys.stderr.write("Spreadsheet appears to be empty\n")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
sys.stdout.write("\n\n".join(sections))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
main()
|
|
@@ -263,7 +263,7 @@ module Clacky
|
|
|
263
263
|
# submitted immediately (the session starts running); display_message
|
|
264
264
|
# controls the user-facing bubble shown in place of the raw prompt.
|
|
265
265
|
def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
|
|
266
|
-
source: :manual, display_message: nil)
|
|
266
|
+
source: :manual, display_message: nil, hidden: false)
|
|
267
267
|
error!("server not ready", status: 503) unless @http_server
|
|
268
268
|
|
|
269
269
|
session_id = @http_server.send(
|
|
@@ -271,7 +271,8 @@ module Clacky
|
|
|
271
271
|
name: name,
|
|
272
272
|
working_dir: working_dir,
|
|
273
273
|
profile: profile,
|
|
274
|
-
source: source
|
|
274
|
+
source: source,
|
|
275
|
+
hidden: hidden
|
|
275
276
|
)
|
|
276
277
|
|
|
277
278
|
submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
|
|
@@ -77,7 +77,33 @@ module Clacky
|
|
|
77
77
|
body[:system] = system_text unless system_text.empty?
|
|
78
78
|
body[:tools] = api_tools if api_tools&.any?
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
# Kimi (Moonshot Coding Plan) models routed through the Anthropic
|
|
81
|
+
# /v1/messages format expect thinking:{type:"enabled"} — the native
|
|
82
|
+
# Anthropic "adaptive" type is Claude-specific and is silently ignored
|
|
83
|
+
# by the Kimi backend, so thinking never actually activates on K3.
|
|
84
|
+
#
|
|
85
|
+
# K3 additionally supports a "max" effort level (strongest reasoning)
|
|
86
|
+
# beyond the standard low/medium/high triple. The generic
|
|
87
|
+
# normalized_effort() helper drops "max" because it only whitelists
|
|
88
|
+
# the standard three levels, making K3's signature strongest-reasoning
|
|
89
|
+
# mode unreachable through this format adapter.
|
|
90
|
+
#
|
|
91
|
+
# This branch only changes K3's wire format; every other model keeps
|
|
92
|
+
# using the adaptive path below unchanged. When reasoning_effort is
|
|
93
|
+
# nil/empty we leave the body untouched (provider default), and we
|
|
94
|
+
# never emit output_config for "on" so the backend can pick its own
|
|
95
|
+
# default — both behaviours match the official kimi CLI.
|
|
96
|
+
if model.to_s.match?(/\A(kimi-)?k3/i)
|
|
97
|
+
effort_str = reasoning_effort.to_s
|
|
98
|
+
k3_effort =
|
|
99
|
+
case effort_str
|
|
100
|
+
when "max", "xhigh" then "max"
|
|
101
|
+
when "high", "medium", "low" then effort_str
|
|
102
|
+
else nil
|
|
103
|
+
end
|
|
104
|
+
body[:thinking] = { type: "enabled" }
|
|
105
|
+
body[:output_config] = { effort: k3_effort } if k3_effort
|
|
106
|
+
elsif (effort = normalized_effort(reasoning_effort))
|
|
81
107
|
body[:thinking] = { type: "adaptive" }
|
|
82
108
|
body[:output_config] = { effort: effort }
|
|
83
109
|
end
|
|
@@ -47,7 +47,7 @@ module Clacky
|
|
|
47
47
|
def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true, reasoning_effort: nil)
|
|
48
48
|
api_messages = messages.map { |msg| normalize_message_content(msg, vision_supported: vision_supported) }
|
|
49
49
|
|
|
50
|
-
body = { model: model,
|
|
50
|
+
body = { model: model, token_field_for(model) => max_tokens, messages: api_messages }
|
|
51
51
|
|
|
52
52
|
if tools&.any?
|
|
53
53
|
if caching_enabled
|
|
@@ -59,9 +59,7 @@ module Clacky
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
body[:reasoning_effort] = reasoning_effort.to_s
|
|
64
|
-
end
|
|
62
|
+
apply_reasoning_params(body, model, reasoning_effort)
|
|
65
63
|
|
|
66
64
|
body
|
|
67
65
|
end
|
|
@@ -196,6 +194,96 @@ module Clacky
|
|
|
196
194
|
|
|
197
195
|
# ── Private helpers ───────────────────────────────────────────────────────
|
|
198
196
|
|
|
197
|
+
# Returns the token-limit field name for the given model.
|
|
198
|
+
# Most OpenAI-compatible APIs use :max_tokens; MiMo-V2.5 (Xiaomi) and
|
|
199
|
+
# MiniMax-M3 use :max_completion_tokens to cap the combined thinking +
|
|
200
|
+
# answer length (M3 documents max_completion_tokens over the legacy
|
|
201
|
+
# max_tokens field).
|
|
202
|
+
private_class_method def self.token_field_for(model)
|
|
203
|
+
if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3$/i)
|
|
204
|
+
:max_completion_tokens
|
|
205
|
+
else
|
|
206
|
+
:max_tokens
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Apply model-specific reasoning / thinking parameters to the request body.
|
|
211
|
+
#
|
|
212
|
+
# Different model families expose extended reasoning through different API
|
|
213
|
+
# fields. We map the shared internal reasoning_effort value to each
|
|
214
|
+
# family's native parameters in-place.
|
|
215
|
+
#
|
|
216
|
+
# Zero side-effects: when reasoning_effort is nil/empty the body is
|
|
217
|
+
# unchanged, preserving the provider default for all models.
|
|
218
|
+
private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
|
|
219
|
+
effort_str = reasoning_effort.to_s
|
|
220
|
+
|
|
221
|
+
if model.to_s.match?(/^glm-[45]/i)
|
|
222
|
+
# GLM (Zhipu / Z.ai) supports a native top-level "thinking" field
|
|
223
|
+
# ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
|
|
224
|
+
# that only accepts "max" or "high". Other effort levels collapse
|
|
225
|
+
# to "high". Send both fields so GLM activates thinking correctly.
|
|
226
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
227
|
+
body[:thinking] = { type: "disabled" }
|
|
228
|
+
elsif !effort_str.empty?
|
|
229
|
+
glm_effort =
|
|
230
|
+
case effort_str
|
|
231
|
+
when "max", "xhigh" then "max"
|
|
232
|
+
when "high" then "high"
|
|
233
|
+
when "medium", "low" then "high" # GLM collapses these to "high"
|
|
234
|
+
else "max"
|
|
235
|
+
end
|
|
236
|
+
body[:thinking] = { type: "enabled" }
|
|
237
|
+
body[:reasoning_effort] = glm_effort
|
|
238
|
+
end
|
|
239
|
+
elsif model.to_s.match?(/^kimi-k3/i)
|
|
240
|
+
# Kimi K3 reasoning_effort only accepts low/high/max (default max).
|
|
241
|
+
# Thinking is always enabled and cannot be turned off; "off" maps
|
|
242
|
+
# to the lowest intensity "low". Unsupported effort levels (medium,
|
|
243
|
+
# xhigh) would be rejected or ignored by the server.
|
|
244
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
245
|
+
body[:reasoning_effort] = "low"
|
|
246
|
+
elsif !effort_str.empty?
|
|
247
|
+
body[:reasoning_effort] =
|
|
248
|
+
case effort_str
|
|
249
|
+
when "max", "xhigh" then "max"
|
|
250
|
+
when "high" then "high"
|
|
251
|
+
when "medium", "low" then "low"
|
|
252
|
+
else "max"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
elsif model.to_s.match?(/^mimo-v2/i)
|
|
256
|
+
# MiMo-V2.5 (Xiaomi) controls reasoning via a top-level
|
|
257
|
+
# thinking:{type:...} field rather than reasoning_effort. Map the
|
|
258
|
+
# internal reasoning_effort value to thinking on/off so MiMo does
|
|
259
|
+
# not receive an unsupported parameter.
|
|
260
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
261
|
+
body[:thinking] = { type: "disabled" }
|
|
262
|
+
elsif !effort_str.empty?
|
|
263
|
+
body[:thinking] = { type: "enabled" }
|
|
264
|
+
end
|
|
265
|
+
elsif model.to_s.match?(/^minimax-m3$/i)
|
|
266
|
+
# MiniMax-M3 controls thinking via a top-level "thinking" field with
|
|
267
|
+
# { type: "adaptive" | "disabled" } — the same envelope shape MiMo
|
|
268
|
+
# uses, but with MiniMax-specific values. Adaptive thinking is the
|
|
269
|
+
# server default when the field is omitted, so we only send the
|
|
270
|
+
# field to explicitly switch modes:
|
|
271
|
+
# - reasoning_effort "off"/"disabled" → thinking disabled
|
|
272
|
+
# (M3 answers directly; M2.7 keeps thinking on regardless).
|
|
273
|
+
# - any other effort → adaptive thinking (M3
|
|
274
|
+
# decides per request); we let the server pick the intensity.
|
|
275
|
+
# M2.7 cannot disable thinking and ignores this field, so sending
|
|
276
|
+
# it is harmless for the rest of the MiniMax lineup.
|
|
277
|
+
if %w[off nothink disabled].include?(effort_str)
|
|
278
|
+
body[:thinking] = { type: "disabled" }
|
|
279
|
+
elsif !effort_str.empty?
|
|
280
|
+
body[:thinking] = { type: "adaptive" }
|
|
281
|
+
end
|
|
282
|
+
elsif reasoning_effort && !effort_str.empty?
|
|
283
|
+
body[:reasoning_effort] = effort_str
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
199
287
|
private_class_method def self.parse_tool_calls(raw)
|
|
200
288
|
return nil if raw.nil? || raw.empty?
|
|
201
289
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -29,12 +29,14 @@ module Clacky
|
|
|
29
29
|
"name" => "OpenClacky",
|
|
30
30
|
"base_url" => "https://api.openclacky.com",
|
|
31
31
|
"api" => "bedrock",
|
|
32
|
-
"default_model" => "abs-claude-sonnet-
|
|
32
|
+
"default_model" => "abs-claude-sonnet-5",
|
|
33
33
|
"models" => [
|
|
34
34
|
"abs-claude-fable-5",
|
|
35
|
+
"abs-claude-opus-5",
|
|
35
36
|
"abs-claude-opus-4-8",
|
|
36
37
|
"abs-claude-opus-4-7",
|
|
37
38
|
"abs-claude-opus-4-6",
|
|
39
|
+
"abs-claude-sonnet-5",
|
|
38
40
|
"abs-claude-sonnet-4-6",
|
|
39
41
|
"abs-claude-sonnet-4-5",
|
|
40
42
|
"abs-claude-haiku-4-5",
|
|
@@ -134,9 +136,11 @@ module Clacky
|
|
|
134
136
|
# no injection happens when the default model is already lite-class.
|
|
135
137
|
"lite_models" => {
|
|
136
138
|
"abs-claude-fable-5" => "abs-claude-haiku-4-5",
|
|
139
|
+
"abs-claude-opus-5" => "abs-claude-haiku-4-5",
|
|
137
140
|
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
138
141
|
"abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
|
|
139
142
|
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
143
|
+
"abs-claude-sonnet-5" => "abs-claude-haiku-4-5",
|
|
140
144
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
141
145
|
"abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5",
|
|
142
146
|
"dsk-deepseek-v4-pro" => "dsk-deepseek-v4-flash",
|
|
@@ -145,7 +149,9 @@ module Clacky
|
|
|
145
149
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
146
150
|
# Keys are primary model names; values are the fallback model to use instead.
|
|
147
151
|
"fallback_models" => {
|
|
148
|
-
"abs-claude-fable-5" => "abs-claude-opus-
|
|
152
|
+
"abs-claude-fable-5" => "abs-claude-opus-5",
|
|
153
|
+
"abs-claude-opus-5" => "abs-claude-opus-4-8",
|
|
154
|
+
"abs-claude-sonnet-5" => "abs-claude-sonnet-4-6",
|
|
149
155
|
"abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
|
|
150
156
|
},
|
|
151
157
|
# Secondary gateway URL used when the primary base_url is unreachable
|
|
@@ -254,18 +260,20 @@ module Clacky
|
|
|
254
260
|
# so capability checks (vision=false) fire correctly regardless of
|
|
255
261
|
# which endpoint the user configured.
|
|
256
262
|
"endpoint_variants" => [
|
|
257
|
-
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn",
|
|
258
|
-
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international",
|
|
263
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
|
|
264
|
+
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
|
|
259
265
|
].freeze,
|
|
260
|
-
# MiniMax M2.
|
|
261
|
-
#
|
|
262
|
-
#
|
|
266
|
+
# MiniMax M2.5/M2.7 are text-only on this endpoint. M3 (released 2026-06-01)
|
|
267
|
+
# is natively multimodal — it accepts image input via OpenAI-style
|
|
268
|
+
# image_url content parts — so it overrides the provider-level
|
|
269
|
+
# vision=false below. M3 exposes a 1,000,000-token context window
|
|
270
|
+
# (M2.7: 204,800; M2.5: 40,960).
|
|
263
271
|
"capabilities" => { "vision" => false }.freeze,
|
|
264
272
|
"model_capabilities" => {
|
|
265
273
|
"MiniMax-M3" => { "vision" => true }.freeze
|
|
266
274
|
}.freeze,
|
|
267
275
|
"default_ocr_model" => "MiniMax-M3",
|
|
268
|
-
"website_url" => "https://
|
|
276
|
+
"website_url" => "https://platform.minimax.io/"
|
|
269
277
|
}.freeze,
|
|
270
278
|
|
|
271
279
|
"kimi" => {
|
|
@@ -348,13 +356,30 @@ module Clacky
|
|
|
348
356
|
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
349
357
|
"api" => "openai-completions",
|
|
350
358
|
"default_model" => "mimo-v2.5-pro",
|
|
351
|
-
|
|
352
|
-
#
|
|
359
|
+
# The MiMo-V2 family (mimo-v2-pro / mimo-v2-omni) was retired on
|
|
360
|
+
# 2026-06-30 and the model ids are no longer accepted by the API. The
|
|
361
|
+
# current lineup is the V2.5 series:
|
|
362
|
+
# - mimo-v2.5-pro: text reasoning flagship, no vision
|
|
363
|
+
# - mimo-v2.5: native omni-modal (image/video/audio/text), vision-capable
|
|
364
|
+
# Source: https://platform.xiaomimimo.com/docs/zh-CN/model
|
|
365
|
+
"models" => ["mimo-v2.5-pro", "mimo-v2.5"],
|
|
353
366
|
"capabilities" => { "vision" => false }.freeze,
|
|
354
367
|
"model_capabilities" => {
|
|
355
|
-
"mimo-v2
|
|
368
|
+
"mimo-v2.5" => { "vision" => true }.freeze
|
|
356
369
|
}.freeze,
|
|
357
|
-
"default_ocr_model" => "mimo-v2
|
|
370
|
+
"default_ocr_model" => "mimo-v2.5",
|
|
371
|
+
# Xiaomi serves the same V2.5 lineup from two billing endpoints: the
|
|
372
|
+
# pay-as-you-go API (api.xiaomimimo.com) and the Token Plan subscription
|
|
373
|
+
# endpoint (token-plan-cn.xiaomimimo.com). Both accept identical model
|
|
374
|
+
# ids and share one capability profile, so a single preset with
|
|
375
|
+
# endpoint_variants recognises both. Without this, users on the Token
|
|
376
|
+
# Plan endpoint fell through to "unknown provider" and the conservative
|
|
377
|
+
# vision=true default applied to every model — sending images to the
|
|
378
|
+
# text-only mimo-v2.5-pro, which rejects image input.
|
|
379
|
+
"endpoint_variants" => [
|
|
380
|
+
{ "label" => "Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mimo_payg", "base_url" => "https://api.xiaomimimo.com/v1", "region" => "cn" }.freeze,
|
|
381
|
+
{ "label" => "Token Plan", "label_key" => "settings.models.baseurl.variant.mimo_token_plan", "base_url" => "https://token-plan-cn.xiaomimimo.com/v1", "region" => "cn" }.freeze
|
|
382
|
+
].freeze,
|
|
358
383
|
"website_url" => "https://platform.xiaomimimo.com/"
|
|
359
384
|
}.freeze,
|
|
360
385
|
|
|
@@ -362,8 +387,8 @@ module Clacky
|
|
|
362
387
|
"name" => "GLM (Z.ai / Zhipu)",
|
|
363
388
|
"base_url" => "https://open.bigmodel.cn/api/paas/v4",
|
|
364
389
|
"api" => "openai-completions",
|
|
365
|
-
"default_model" => "glm-5.
|
|
366
|
-
"models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
390
|
+
"default_model" => "glm-5.2",
|
|
391
|
+
"models" => ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
367
392
|
# Zhipu / Z.ai expose four functionally-equivalent endpoints:
|
|
368
393
|
# two regional sites (mainland open.bigmodel.cn + international api.z.ai)
|
|
369
394
|
# each with a general-billing and a Coding-Plan subpath. They share the
|
|
@@ -520,6 +545,22 @@ module Clacky
|
|
|
520
545
|
|
|
521
546
|
MEDIA_KINDS = %w[image video audio stt video_understanding].freeze
|
|
522
547
|
|
|
548
|
+
# Per-model maximum output token limits.
|
|
549
|
+
#
|
|
550
|
+
# The Agent global default (@max_tokens = 16_384) is tuned for the lowest
|
|
551
|
+
# common denominator. Strong reasoning models (GLM-5.2, Kimi-K3, MiMo-V2.5)
|
|
552
|
+
# support 64K–128K output but are artificially throttled to 16K, causing
|
|
553
|
+
# truncation on long reasoning chains and code generation.
|
|
554
|
+
#
|
|
555
|
+
# Entries are matched top-to-bottom; the first match wins. Models not
|
|
556
|
+
# listed here fall back to the global default.
|
|
557
|
+
MODEL_MAX_OUTPUT = [
|
|
558
|
+
{ pattern: /glm/i, limit: 65_536 }, # GLM-5.2: 128K output ceiling; 64K ample for reasoning+answer
|
|
559
|
+
{ pattern: /kimi-k3/i, limit: 65_536 }, # Kimi K3: max_completion_tokens=131072 (max 1M); 64K ample
|
|
560
|
+
{ pattern: /mimo-v2\.5-pro/i, limit: 65_536 }, # MiMo-V2.5-Pro: max_completion_tokens=131072; 64K ample
|
|
561
|
+
{ pattern: /mimo/i, limit: 32_768 } # MiMo-V2.5: max_completion_tokens=32768; full default ceiling
|
|
562
|
+
].freeze
|
|
563
|
+
|
|
523
564
|
class << self
|
|
524
565
|
# Check if a provider preset exists
|
|
525
566
|
# @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
|
|
@@ -559,6 +600,18 @@ module Clacky
|
|
|
559
600
|
preset&.dig("api")
|
|
560
601
|
end
|
|
561
602
|
|
|
603
|
+
# Resolve the per-model maximum output token limit.
|
|
604
|
+
# Returns nil when no MODEL_MAX_OUTPUT entry matches — callers should
|
|
605
|
+
# then fall back to the global default (@max_tokens).
|
|
606
|
+
#
|
|
607
|
+
# @param model_name [String] The model name (e.g. "glm-5.2")
|
|
608
|
+
# @return [Integer, nil] The max output limit, or nil if undeclared
|
|
609
|
+
def max_output_for(model_name)
|
|
610
|
+
return nil if model_name.nil? || model_name.to_s.empty?
|
|
611
|
+
entry = MODEL_MAX_OUTPUT.find { |e| model_name.to_s.match?(e[:pattern]) }
|
|
612
|
+
entry&.[](:limit)
|
|
613
|
+
end
|
|
614
|
+
|
|
562
615
|
# Resolve the API type for a specific provider+model pair.
|
|
563
616
|
#
|
|
564
617
|
# Resolution order:
|