openclacky 1.0.0 → 1.0.2
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/CHANGELOG.md +39 -0
- data/README.md +87 -53
- data/lib/clacky/agent/cost_tracker.rb +19 -2
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor_helper.rb +32 -2
- data/lib/clacky/agent.rb +54 -22
- data/lib/clacky/client.rb +44 -5
- data/lib/clacky/default_parsers/pdf_parser.rb +58 -17
- data/lib/clacky/default_parsers/pdf_parser_ocr.py +103 -0
- data/lib/clacky/default_parsers/pdf_parser_plumber.py +62 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +201 -77
- data/lib/clacky/default_skills/new/SKILL.md +3 -114
- data/lib/clacky/default_skills/onboard/SKILL.md +349 -133
- data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +371 -0
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/message_format/anthropic.rb +72 -8
- data/lib/clacky/message_format/bedrock.rb +6 -3
- data/lib/clacky/providers.rb +146 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +746 -13
- data/lib/clacky/server/session_registry.rb +55 -24
- data/lib/clacky/skill.rb +10 -9
- data/lib/clacky/skill_loader.rb +23 -11
- data/lib/clacky/tools/file_reader.rb +232 -127
- data/lib/clacky/tools/security.rb +42 -64
- data/lib/clacky/tools/terminal/persistent_session.rb +15 -4
- data/lib/clacky/tools/terminal/safe_rm.sh +106 -0
- data/lib/clacky/tools/terminal/session_manager.rb +8 -3
- data/lib/clacky/tools/terminal.rb +263 -16
- data/lib/clacky/ui2/layout_manager.rb +8 -1
- data/lib/clacky/ui2/output_buffer.rb +83 -23
- data/lib/clacky/ui2/ui_controller.rb +74 -7
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +215 -0
- data/lib/clacky/utils/parser_manager.rb +70 -6
- data/lib/clacky/utils/string_matcher.rb +23 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +673 -9
- data/lib/clacky/web/app.js +40 -1608
- data/lib/clacky/web/i18n.js +209 -0
- data/lib/clacky/web/index.html +166 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/profile.js +442 -0
- data/lib/clacky/web/sessions.js +1034 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/sidebar.js +39 -0
- data/lib/clacky/web/skills.js +460 -0
- data/lib/clacky/web/trash.js +343 -0
- data/lib/clacky/web/ws-dispatcher.js +255 -0
- data/lib/clacky.rb +5 -3
- metadata +16 -17
- data/lib/clacky/clacky_auth_client.rb +0 -152
- data/lib/clacky/clacky_cloud_config.rb +0 -123
- data/lib/clacky/cloud_project_client.rb +0 -169
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +0 -1377
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +0 -116
- data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +0 -341
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +0 -99
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +0 -77
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +0 -67
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +0 -189
- data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +0 -74
- data/lib/clacky/deploy_api_client.rb +0 -484
|
@@ -91,13 +91,53 @@ module Clacky
|
|
|
91
91
|
else data["stop_reason"]
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
|
|
95
|
+
# input — cache_read_input_tokens and cache_creation_input_tokens are
|
|
96
|
+
# reported separately and are disjoint from input_tokens.
|
|
97
|
+
#
|
|
98
|
+
# Normalise to the codebase's canonical shape (OpenAI-style) so downstream
|
|
99
|
+
# (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
|
|
100
|
+
# provider-agnostic:
|
|
101
|
+
#
|
|
102
|
+
# prompt_tokens = non_cached + cache_read (OpenAI convention:
|
|
103
|
+
# includes cache_read
|
|
104
|
+
# but NOT cache_write;
|
|
105
|
+
# ModelPricing does
|
|
106
|
+
# `regular_input = prompt_tokens - cache_read`.)
|
|
107
|
+
# completion_tokens = output
|
|
108
|
+
# total_tokens = THIS TURN'S new compute volume
|
|
109
|
+
# = raw_input + cache_creation + output
|
|
110
|
+
# (cache_read is excluded because hits are ~free /
|
|
111
|
+
# already-paid-for; cache_creation IS new work this
|
|
112
|
+
# turn even though it's billed at write_rate.)
|
|
113
|
+
# cache_read_input_tokens / cache_creation_input_tokens → independent fields
|
|
114
|
+
#
|
|
115
|
+
# total_tokens is purely presentational. CostTracker treats it as the
|
|
116
|
+
# per-iteration delta directly (no subtraction of previous_total), which
|
|
117
|
+
# is the correct reading when total_tokens already means "new work this
|
|
118
|
+
# turn" rather than "cumulative".
|
|
119
|
+
raw_input_tokens = usage["input_tokens"].to_i
|
|
120
|
+
cache_read = usage["cache_read_input_tokens"].to_i
|
|
121
|
+
cache_creation = usage["cache_creation_input_tokens"].to_i
|
|
122
|
+
output_tokens = usage["output_tokens"].to_i
|
|
123
|
+
|
|
124
|
+
prompt_tokens = raw_input_tokens + cache_read
|
|
125
|
+
|
|
94
126
|
usage_data = {
|
|
95
|
-
prompt_tokens:
|
|
96
|
-
completion_tokens:
|
|
97
|
-
|
|
127
|
+
prompt_tokens: prompt_tokens,
|
|
128
|
+
completion_tokens: output_tokens,
|
|
129
|
+
# Per-turn new compute: what the server freshly processed this request.
|
|
130
|
+
# Excludes cache_read (nearly free, already-paid-for).
|
|
131
|
+
total_tokens: raw_input_tokens + cache_creation + output_tokens,
|
|
132
|
+
# Signal to CostTracker: total_tokens above is already the per-turn
|
|
133
|
+
# delta (not a running cumulative like OpenAI's). CostTracker should
|
|
134
|
+
# NOT subtract previous_total when this flag is truthy.
|
|
135
|
+
# OpenAI parse leaves this field unset; Bedrock may adopt the same
|
|
136
|
+
# convention in future if we normalise it there too.
|
|
137
|
+
total_is_per_turn: true
|
|
98
138
|
}
|
|
99
|
-
usage_data[:cache_read_input_tokens] =
|
|
100
|
-
usage_data[:cache_creation_input_tokens] =
|
|
139
|
+
usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
|
|
140
|
+
usage_data[:cache_creation_input_tokens] = cache_creation if cache_creation > 0
|
|
101
141
|
|
|
102
142
|
{ content: content, tool_calls: tool_calls, finish_reason: finish_reason,
|
|
103
143
|
usage: usage_data, raw_api_usage: usage }
|
|
@@ -151,15 +191,39 @@ module Clacky
|
|
|
151
191
|
|
|
152
192
|
# canonical tool result (role: "tool") → Anthropic user message with tool_result block
|
|
153
193
|
if role == "tool"
|
|
194
|
+
# Strip any cache_control that Client#apply_message_caching may have
|
|
195
|
+
# embedded INSIDE msg[:content] (it wraps string content as
|
|
196
|
+
# [{type:"text", text:..., cache_control:{...}}]). We hoist that
|
|
197
|
+
# marker up to the tool_result block itself below — that's where
|
|
198
|
+
# Anthropic expects the marker for a tool_result turn.
|
|
199
|
+
#
|
|
200
|
+
# CRITICAL: if we leave cache_control on the inner text block, the
|
|
201
|
+
# tool_result.content shape flips between "string" and
|
|
202
|
+
# "[{text,cache_control}]" depending on whether this message is the
|
|
203
|
+
# current cache breakpoint — which mutates the cached prefix every
|
|
204
|
+
# turn and destroys cache_read hit-rate (the classic "cache_read
|
|
205
|
+
# stuck at tiny number" symptom).
|
|
206
|
+
hoisted_cache_control = nil
|
|
207
|
+
raw_content = msg[:content]
|
|
208
|
+
if raw_content.is_a?(Array) &&
|
|
209
|
+
raw_content.length == 1 &&
|
|
210
|
+
raw_content.first.is_a?(Hash) &&
|
|
211
|
+
raw_content.first[:type] == "text" &&
|
|
212
|
+
raw_content.first[:cache_control]
|
|
213
|
+
hoisted_cache_control = raw_content.first[:cache_control]
|
|
214
|
+
raw_content = raw_content.first[:text]
|
|
215
|
+
end
|
|
216
|
+
|
|
154
217
|
# If content is an Array of canonical blocks (e.g. image_url + text from file_reader),
|
|
155
218
|
# convert each block to Anthropic format via content_to_blocks.
|
|
156
219
|
# Plain strings pass through unchanged.
|
|
157
|
-
tool_content = if
|
|
158
|
-
content_to_blocks(
|
|
220
|
+
tool_content = if raw_content.is_a?(Array)
|
|
221
|
+
content_to_blocks(raw_content)
|
|
159
222
|
else
|
|
160
|
-
|
|
223
|
+
raw_content
|
|
161
224
|
end
|
|
162
225
|
block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
|
|
226
|
+
block[:cache_control] = hoisted_cache_control if hoisted_cache_control
|
|
163
227
|
return { role: "user", content: [block] }
|
|
164
228
|
end
|
|
165
229
|
|
|
@@ -118,11 +118,14 @@ module Clacky
|
|
|
118
118
|
cache_write = usage["cacheWriteInputTokens"].to_i
|
|
119
119
|
|
|
120
120
|
# Bedrock `inputTokens` = non-cached input only.
|
|
121
|
-
# Anthropic direct `input_tokens` =
|
|
122
|
-
#
|
|
121
|
+
# Anthropic direct `input_tokens` = ALSO non-cached input only
|
|
122
|
+
# (cache_read_input_tokens and cache_creation_input_tokens are reported
|
|
123
|
+
# separately and are disjoint from input_tokens — NOT included in it).
|
|
124
|
+
# Normalise to the OpenAI/Bedrock convention so ModelPricing.calculate_cost
|
|
125
|
+
# works correctly:
|
|
123
126
|
# prompt_tokens = inputTokens + cacheReadInputTokens
|
|
124
127
|
# (calculate_cost subtracts cache_read_tokens from prompt_tokens to get
|
|
125
|
-
# the billable non-cached portion;
|
|
128
|
+
# the billable non-cached portion; cache_write is priced on top.)
|
|
126
129
|
prompt_tokens = usage["inputTokens"].to_i + cache_read
|
|
127
130
|
|
|
128
131
|
usage_data = {
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -17,6 +17,13 @@ module Clacky
|
|
|
17
17
|
# { "<model_name>" => { "<cap>" => bool, ... } }. Use this when a
|
|
18
18
|
# single provider hosts models with different capabilities (e.g.
|
|
19
19
|
# openclacky hosts both vision-capable Claude and text-only DeepSeek).
|
|
20
|
+
# - model_api_overrides (optional): per-model API-type override map,
|
|
21
|
+
# { <Regexp|String> => "anthropic-messages" | "openai-completions" | ... }.
|
|
22
|
+
# Keys can be a plain model name or a Regexp matched against the model.
|
|
23
|
+
# The first key that matches wins; if none match, the provider's top-level
|
|
24
|
+
# "api" is used. Used so e.g. OpenRouter can keep "openai-responses" as
|
|
25
|
+
# its default while routing Claude models through the native Anthropic
|
|
26
|
+
# endpoint (which preserves cache_control fidelity).
|
|
20
27
|
PRESETS = {
|
|
21
28
|
"openclacky" => {
|
|
22
29
|
"name" => "OpenClacky",
|
|
@@ -74,6 +81,19 @@ module Clacky
|
|
|
74
81
|
"api" => "openai-responses",
|
|
75
82
|
"default_model" => "anthropic/claude-sonnet-4-6",
|
|
76
83
|
"models" => [], # Dynamic - fetched from API
|
|
84
|
+
# Per-model API type overrides. Matched by Regexp against the model name.
|
|
85
|
+
# Why this exists: OpenRouter proxies Claude via both its OpenAI-compatible
|
|
86
|
+
# /chat/completions endpoint AND a native Anthropic /v1/messages endpoint.
|
|
87
|
+
# The OpenAI shim is lossy for Claude's cache_control semantics — prefix
|
|
88
|
+
# rewrites inside the proxy cause ~10% prompt-cache misses. Pinning
|
|
89
|
+
# "anthropic/*" (and any direct "claude-*" alias) to the native Anthropic
|
|
90
|
+
# endpoint preserves cache_control byte-for-byte and matches what Claude
|
|
91
|
+
# Code CLI does internally. Non-Claude models (Gemini, GPT, etc.) keep
|
|
92
|
+
# the OpenAI shim — that's what OpenRouter documents as their primary.
|
|
93
|
+
"model_api_overrides" => {
|
|
94
|
+
/\Aanthropic\// => "anthropic-messages",
|
|
95
|
+
/\Aclaude[-.]/ => "anthropic-messages"
|
|
96
|
+
}.freeze,
|
|
77
97
|
"website_url" => "https://openrouter.ai/keys"
|
|
78
98
|
}.freeze,
|
|
79
99
|
|
|
@@ -105,6 +125,15 @@ module Clacky
|
|
|
105
125
|
"api" => "openai-completions",
|
|
106
126
|
"default_model" => "MiniMax-M2.7",
|
|
107
127
|
"models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
|
|
128
|
+
# MiniMax operates two regional endpoints with identical APIs & model
|
|
129
|
+
# lineup — mainland China (.com) and international (.io). Listing both
|
|
130
|
+
# lets find_by_base_url identify either one as provider "minimax",
|
|
131
|
+
# so capability checks (vision=false) fire correctly regardless of
|
|
132
|
+
# which endpoint the user configured.
|
|
133
|
+
"endpoint_variants" => [
|
|
134
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
|
|
135
|
+
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
|
|
136
|
+
].freeze,
|
|
108
137
|
# MiniMax M2.x does not support multimodal/vision input on this endpoint.
|
|
109
138
|
"capabilities" => { "vision" => false }.freeze,
|
|
110
139
|
"website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
|
|
@@ -116,6 +145,17 @@ module Clacky
|
|
|
116
145
|
"api" => "openai-completions",
|
|
117
146
|
"default_model" => "kimi-k2.6",
|
|
118
147
|
"models" => ["kimi-k2.6", "kimi-k2.5"],
|
|
148
|
+
# Moonshot operates two regional endpoints with identical APIs & model
|
|
149
|
+
# lineup — mainland China (.cn) and international (.ai). Kimi does not
|
|
150
|
+
# distinguish pay-as-you-go vs coding-plan at the base_url level, so
|
|
151
|
+
# only two variants are needed. Listing both here lets find_by_base_url
|
|
152
|
+
# identify either one as provider "kimi", so downstream capability
|
|
153
|
+
# checks, fallback chains, and provider-specific behaviours work
|
|
154
|
+
# regardless of which endpoint the user configured.
|
|
155
|
+
"endpoint_variants" => [
|
|
156
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
|
|
157
|
+
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
|
|
158
|
+
].freeze,
|
|
119
159
|
# k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
|
|
120
160
|
"capabilities" => { "vision" => true }.freeze,
|
|
121
161
|
"website_url" => "https://platform.moonshot.cn/console/api-keys"
|
|
@@ -172,17 +212,56 @@ module Clacky
|
|
|
172
212
|
}.freeze,
|
|
173
213
|
|
|
174
214
|
"glm" => {
|
|
175
|
-
"name" => "GLM (
|
|
215
|
+
"name" => "GLM (Z.ai / Zhipu)",
|
|
176
216
|
"base_url" => "https://open.bigmodel.cn/api/paas/v4",
|
|
177
217
|
"api" => "openai-completions",
|
|
178
218
|
"default_model" => "glm-5.1",
|
|
179
219
|
"models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
220
|
+
# Zhipu / Z.ai expose four functionally-equivalent endpoints:
|
|
221
|
+
# two regional sites (mainland open.bigmodel.cn + international api.z.ai)
|
|
222
|
+
# each with a general-billing and a Coding-Plan subpath. They share the
|
|
223
|
+
# same model lineup & identical capability profile, so a single preset
|
|
224
|
+
# with endpoint_variants is the right shape — one source of truth for
|
|
225
|
+
# vision/model_capabilities, four URLs recognised by find_by_base_url.
|
|
226
|
+
# Without this, users pointing at api.z.ai or the /coding/ path fell
|
|
227
|
+
# through to the conservative "assume vision=true" default and got
|
|
228
|
+
# hallucinated image descriptions on text-only GLM models (C-5563).
|
|
229
|
+
"endpoint_variants" => [
|
|
230
|
+
{ "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,
|
|
231
|
+
{ "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,
|
|
232
|
+
{ "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,
|
|
233
|
+
{ "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
|
|
234
|
+
].freeze,
|
|
180
235
|
# GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
|
|
181
236
|
"capabilities" => { "vision" => false }.freeze,
|
|
182
237
|
"model_capabilities" => {
|
|
183
238
|
"glm-5v-turbo" => { "vision" => true }.freeze
|
|
184
239
|
}.freeze,
|
|
185
240
|
"website_url" => "https://open.bigmodel.cn/usercenter/apikeys"
|
|
241
|
+
}.freeze,
|
|
242
|
+
|
|
243
|
+
"openai" => {
|
|
244
|
+
"name" => "OpenAI (GPT)",
|
|
245
|
+
"base_url" => "https://api.openai.com/v1",
|
|
246
|
+
"api" => "openai-completions",
|
|
247
|
+
"default_model" => "gpt-5.5",
|
|
248
|
+
"models" => [
|
|
249
|
+
"gpt-5.5",
|
|
250
|
+
"gpt-5.4",
|
|
251
|
+
"gpt-5.4-mini",
|
|
252
|
+
"gpt-5.4-nano",
|
|
253
|
+
"o4-mini",
|
|
254
|
+
"o3"
|
|
255
|
+
],
|
|
256
|
+
# GPT-5.x and o-series models are multimodal (text + image input).
|
|
257
|
+
"capabilities" => { "vision" => true }.freeze,
|
|
258
|
+
# Per-primary lite pairing: subagents use mini/nano for cheap/fast work.
|
|
259
|
+
# o4-mini and o3 are reasoning models without a lite-tier sibling here.
|
|
260
|
+
"lite_models" => {
|
|
261
|
+
"gpt-5.5" => "gpt-5.4-mini",
|
|
262
|
+
"gpt-5.4" => "gpt-5.4-mini"
|
|
263
|
+
},
|
|
264
|
+
"website_url" => "https://platform.openai.com/api-keys"
|
|
186
265
|
}.freeze
|
|
187
266
|
|
|
188
267
|
}.freeze
|
|
@@ -226,6 +305,51 @@ module Clacky
|
|
|
226
305
|
preset&.dig("api")
|
|
227
306
|
end
|
|
228
307
|
|
|
308
|
+
# Resolve the API type for a specific provider+model pair.
|
|
309
|
+
#
|
|
310
|
+
# Resolution order:
|
|
311
|
+
# 1. PRESETS[provider_id]["model_api_overrides"] — first key (String or
|
|
312
|
+
# Regexp) that matches the model name wins.
|
|
313
|
+
# 2. PRESETS[provider_id]["api"] — the provider-wide default.
|
|
314
|
+
# 3. nil — unknown provider.
|
|
315
|
+
#
|
|
316
|
+
# Use this instead of api_type when you need the precise transport for a
|
|
317
|
+
# given model (e.g. routing OpenRouter's Claude requests to the native
|
|
318
|
+
# /v1/messages endpoint to preserve prompt-cache fidelity).
|
|
319
|
+
#
|
|
320
|
+
# @param provider_id [String] The provider identifier
|
|
321
|
+
# @param model_name [String, nil] The specific model name
|
|
322
|
+
# @return [String, nil] The API type (e.g. "anthropic-messages")
|
|
323
|
+
def api_type_for_model(provider_id, model_name)
|
|
324
|
+
preset = PRESETS[provider_id]
|
|
325
|
+
return nil unless preset
|
|
326
|
+
|
|
327
|
+
overrides = preset["model_api_overrides"]
|
|
328
|
+
if overrides.is_a?(Hash) && model_name
|
|
329
|
+
name = model_name.to_s
|
|
330
|
+
matched = overrides.find do |pattern, _api|
|
|
331
|
+
case pattern
|
|
332
|
+
when Regexp then pattern.match?(name)
|
|
333
|
+
when String then pattern == name
|
|
334
|
+
else false
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
return matched[1] if matched
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
preset["api"]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Returns true when the provider+model should be talked to using the
|
|
344
|
+
# native Anthropic /v1/messages format. This is the single source of
|
|
345
|
+
# truth for deciding anthropic_format at Client construction time.
|
|
346
|
+
# @param provider_id [String] The provider identifier
|
|
347
|
+
# @param model_name [String, nil] The specific model name
|
|
348
|
+
# @return [Boolean]
|
|
349
|
+
def anthropic_format_for_model?(provider_id, model_name)
|
|
350
|
+
api_type_for_model(provider_id, model_name) == "anthropic-messages"
|
|
351
|
+
end
|
|
352
|
+
|
|
229
353
|
# List all available provider IDs
|
|
230
354
|
# @return [Array<String>] List of provider identifiers
|
|
231
355
|
def provider_ids
|
|
@@ -287,14 +411,33 @@ module Clacky
|
|
|
287
411
|
# Find provider ID by base URL.
|
|
288
412
|
# Matches if the given URL starts with the provider's base_url (after normalisation),
|
|
289
413
|
# so both exact matches and sub-path variants (e.g. "/v1") are recognised.
|
|
414
|
+
#
|
|
415
|
+
# Also scans `endpoint_variants` (when present) so providers that operate
|
|
416
|
+
# multiple regional / billing-plan endpoints under the same identity
|
|
417
|
+
# (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
|
|
418
|
+
# all recognised as that single provider — one capability definition,
|
|
419
|
+
# N entry URLs. Without this, users configured with a non-default
|
|
420
|
+
# variant fall back to the "unknown provider" path and miss capability
|
|
421
|
+
# enforcement (see C-5563).
|
|
290
422
|
# @param base_url [String] The base URL to look up
|
|
291
423
|
# @return [String, nil] The provider ID or nil if not found
|
|
292
424
|
def find_by_base_url(base_url)
|
|
293
425
|
return nil if base_url.nil? || base_url.empty?
|
|
294
426
|
normalized = base_url.to_s.chomp("/")
|
|
295
427
|
PRESETS.find do |_id, preset|
|
|
296
|
-
|
|
297
|
-
|
|
428
|
+
# Collect every URL this preset claims: the canonical base_url plus
|
|
429
|
+
# any declared endpoint_variants. Dedup so the canonical one showing
|
|
430
|
+
# up in both lists doesn't change behaviour.
|
|
431
|
+
candidates = [preset["base_url"]]
|
|
432
|
+
variants = preset["endpoint_variants"]
|
|
433
|
+
if variants.is_a?(Array)
|
|
434
|
+
variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
|
|
435
|
+
end
|
|
436
|
+
candidates.compact.uniq.any? do |candidate|
|
|
437
|
+
preset_base = candidate.to_s.chomp("/")
|
|
438
|
+
next false if preset_base.empty?
|
|
439
|
+
normalized == preset_base || normalized.start_with?("#{preset_base}/")
|
|
440
|
+
end
|
|
298
441
|
end&.first
|
|
299
442
|
end
|
|
300
443
|
|
|
@@ -166,6 +166,20 @@ module Clacky
|
|
|
166
166
|
# @param event [Hash] Parsed message event
|
|
167
167
|
# @return [void]
|
|
168
168
|
def handle_message_event(event)
|
|
169
|
+
# In group chats, only respond when the bot is explicitly @-mentioned.
|
|
170
|
+
# Private chats always respond.
|
|
171
|
+
# Fail closed: if the bot's own open_id cannot be fetched (API error,
|
|
172
|
+
# bad credentials, etc.), drop group messages instead of responding to
|
|
173
|
+
# every message and spamming the group.
|
|
174
|
+
if event[:chat_type] == :group
|
|
175
|
+
bot_id = @bot.bot_open_id
|
|
176
|
+
if bot_id.nil?
|
|
177
|
+
Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
return unless Array(event[:mentioned_open_ids]).include?(bot_id)
|
|
181
|
+
end
|
|
182
|
+
|
|
169
183
|
allowed_users = @config[:allowed_users]
|
|
170
184
|
if allowed_users && !allowed_users.empty?
|
|
171
185
|
return unless allowed_users.include?(event[:user_id])
|
|
@@ -226,6 +226,16 @@ module Clacky
|
|
|
226
226
|
}.join
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
# Get this bot's own open_id (cached, fetched lazily on first use).
|
|
230
|
+
# Used to detect @bot mentions in group chats.
|
|
231
|
+
# @return [String, nil] bot open_id, or nil if the API call fails
|
|
232
|
+
def bot_open_id
|
|
233
|
+
@bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
|
|
234
|
+
rescue => e
|
|
235
|
+
Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
229
239
|
# Get tenant access token (cached)
|
|
230
240
|
# @return [String] Access token
|
|
231
241
|
def tenant_access_token
|
|
@@ -27,8 +27,14 @@ module Clacky
|
|
|
27
27
|
# @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
|
|
28
28
|
# @param interrupt_session [Proc] (session_id) — from HttpServer
|
|
29
29
|
# @param channel_config [Clacky::ChannelConfig]
|
|
30
|
-
# @param binding_mode [:user | :chat] how to map IM identities to sessions
|
|
31
|
-
|
|
30
|
+
# @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
|
|
31
|
+
# :chat_user (default) — one session per (chat, user) pair. Most natural:
|
|
32
|
+
# private chat = that user's session; in a group each
|
|
33
|
+
# user has their own session; the same user across
|
|
34
|
+
# different groups keeps those contexts separate.
|
|
35
|
+
# :chat — one session per chat (all users in a group share it).
|
|
36
|
+
# :user — one session per user (merges DMs and all groups).
|
|
37
|
+
def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
|
|
32
38
|
@registry = session_registry
|
|
33
39
|
@session_builder = session_builder
|
|
34
40
|
@run_agent_task = run_agent_task
|
|
@@ -354,8 +360,10 @@ module Clacky
|
|
|
354
360
|
def channel_key(event)
|
|
355
361
|
platform = event[:platform].to_s
|
|
356
362
|
case @binding_mode
|
|
357
|
-
when :chat
|
|
358
|
-
|
|
363
|
+
when :chat then "#{platform}:chat:#{event[:chat_id]}"
|
|
364
|
+
when :user then "#{platform}:user:#{event[:user_id]}"
|
|
365
|
+
else # :chat_user (default)
|
|
366
|
+
"#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
|
|
359
367
|
end
|
|
360
368
|
end
|
|
361
369
|
|
|
@@ -33,9 +33,15 @@ module Clacky
|
|
|
33
33
|
|
|
34
34
|
# Update the reply context for the current inbound message.
|
|
35
35
|
# Called at the start of each route_message so replies are threaded correctly.
|
|
36
|
-
#
|
|
36
|
+
# Also updates chat_id — a session may span multiple chats (e.g. same user
|
|
37
|
+
# in both a direct message and a group), and each inbound event dictates
|
|
38
|
+
# where outbound replies should be routed.
|
|
39
|
+
# @param event [Hash] inbound event with :message_id and :chat_id
|
|
37
40
|
def update_message_context(event)
|
|
38
|
-
@mutex.synchronize
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@message_id = event[:message_id]
|
|
43
|
+
@chat_id = event[:chat_id] if event[:chat_id]
|
|
44
|
+
end
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
# === Output display ===
|