openclacky 1.5.11 → 1.5.13
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 +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- metadata +12 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../platform_http_client"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Billing
|
|
8
|
+
# Authoritative usage data from the OpenClacky platform.
|
|
9
|
+
#
|
|
10
|
+
# The gateway records usage against real upstream model ids, while the
|
|
11
|
+
# client persists the user-facing alias (e.g. "dsk-deepseek-v4-pro" is
|
|
12
|
+
# stored as "deepseek-v4-pro" upstream). These tables bridge the two so
|
|
13
|
+
# the billing UI can merge platform data with local data under one name.
|
|
14
|
+
module PlatformBilling
|
|
15
|
+
# alias → real upstream model id, used to translate the model filter
|
|
16
|
+
# before querying the platform API. Vertex ids are preferred as the
|
|
17
|
+
# "primary" id where the gateway can dispatch an alias two ways.
|
|
18
|
+
ALIAS_TO_REAL = {
|
|
19
|
+
# deepseek (dsk-)
|
|
20
|
+
"dsk-deepseek-v4-pro" => "deepseek-v4-pro",
|
|
21
|
+
"dsk-deepseek-v4-flash" => "deepseek-v4-flash",
|
|
22
|
+
"dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
|
|
23
|
+
# claude via bedrock (abs-)
|
|
24
|
+
"abs-claude-fable-5-1" => "global.anthropic.claude-fable-5-1",
|
|
25
|
+
"abs-claude-fable-5" => "global.anthropic.claude-fable-5",
|
|
26
|
+
"abs-claude-opus-5" => "global.anthropic.claude-opus-5",
|
|
27
|
+
"abs-claude-opus-4-8" => "global.anthropic.claude-opus-4-8",
|
|
28
|
+
"abs-claude-opus-4-7" => "global.anthropic.claude-opus-4-7",
|
|
29
|
+
"abs-claude-opus-4-6" => "global.anthropic.claude-opus-4-6",
|
|
30
|
+
"abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
|
|
31
|
+
"abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
|
|
32
|
+
"abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
|
|
33
|
+
"abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
|
|
34
|
+
# gemini chat (or-)
|
|
35
|
+
"or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
|
|
36
|
+
"or-gemini-3-8-flash" => "gemini-3.8-flash",
|
|
37
|
+
"or-gemini-3-7-flash" => "gemini-3.7-flash",
|
|
38
|
+
"or-gemini-3-6-flash" => "gemini-3.6-flash",
|
|
39
|
+
"or-gemini-3-5-flash" => "gemini-3.5-flash",
|
|
40
|
+
# image generation (or-)
|
|
41
|
+
"or-gemini-3-pro-image" => "gemini-3-pro-image",
|
|
42
|
+
"or-gemini-3-1-flash-image" => "gemini-3.1-flash-image",
|
|
43
|
+
"or-gpt-image-2" => "openai/gpt-5.4-image-2",
|
|
44
|
+
# video generation (or-)
|
|
45
|
+
"or-veo-3" => "veo-3.0-generate-001",
|
|
46
|
+
"or-veo-3-fast" => "veo-3.0-fast-generate-001",
|
|
47
|
+
"or-veo-3-1" => "veo-3.1-generate-001",
|
|
48
|
+
"or-veo-3-1-fast" => "veo-3.1-fast-generate-001",
|
|
49
|
+
# text-to-speech (or-)
|
|
50
|
+
"or-tts-gemini-2-5-flash" => "gemini-2.5-flash-tts",
|
|
51
|
+
"or-tts-gemini-2-5-pro" => "gemini-2.5-pro-tts",
|
|
52
|
+
# speech-to-text (or-)
|
|
53
|
+
"or-stt-gemini-3-8-flash" => "gemini-3.8-flash",
|
|
54
|
+
"or-stt-gemini-3-7-flash" => "gemini-3.7-flash",
|
|
55
|
+
"or-stt-gemini-3-6-flash" => "gemini-3.6-flash",
|
|
56
|
+
"or-stt-gemini-3-5-flash" => "gemini-3.5-flash",
|
|
57
|
+
"or-stt-gemini-1-5-pro" => "gemini-1.5-pro-002"
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# real upstream model id → alias, for display. Includes the OpenRouter
|
|
61
|
+
# fallback ids (google/…-preview) the gateway records when Vertex is
|
|
62
|
+
# disabled, in addition to the primary Vertex ids in ALIAS_TO_REAL.
|
|
63
|
+
REAL_TO_ALIAS = ALIAS_TO_REAL.invert.merge(
|
|
64
|
+
"google/gemini-3.1-pro-preview" => "or-gemini-3-1-pro",
|
|
65
|
+
"google/gemini-3-pro-image-preview" => "or-gemini-3-pro-image",
|
|
66
|
+
# STT aliases reuse the chat real id; Hash#invert keeps the later STT
|
|
67
|
+
# key, so pin these back to the chat alias for display.
|
|
68
|
+
"gemini-3.8-flash" => "or-gemini-3-8-flash",
|
|
69
|
+
"gemini-3.7-flash" => "or-gemini-3-7-flash",
|
|
70
|
+
"gemini-3.6-flash" => "or-gemini-3-6-flash",
|
|
71
|
+
"gemini-3.5-flash" => "or-gemini-3-5-flash"
|
|
72
|
+
).freeze
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# Translate a real upstream model id back to the user-facing alias.
|
|
76
|
+
# Unknown ids pass through unchanged (other providers' local records).
|
|
77
|
+
def display_model(real_id)
|
|
78
|
+
REAL_TO_ALIAS.fetch(real_id, real_id)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Translate a user-facing alias to the primary real upstream model id.
|
|
82
|
+
# Returns nil when the alias is not an openclacky model.
|
|
83
|
+
def real_model(alias_name)
|
|
84
|
+
ALIAS_TO_REAL[alias_name]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fetch and merge usage summaries across multiple openclacky keys
|
|
88
|
+
# (e.g. several accounts). The platform bills per key, so summing
|
|
89
|
+
# per-key results yields the combined total. Failing keys
|
|
90
|
+
# (invalid/revoked) are skipped; returns nil only when every key fails.
|
|
91
|
+
def fetch_summary_merged(api_keys, period:, model: nil)
|
|
92
|
+
summaries = Array(api_keys).uniq.map { |key| fetch_summary(key, period: period, model: model) }.compact
|
|
93
|
+
return nil if summaries.empty?
|
|
94
|
+
|
|
95
|
+
summaries.reduce { |acc, summary| merge_summaries(acc, summary) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Fetch and merge daily breakdowns across multiple openclacky keys.
|
|
99
|
+
# Same skip-on-failure semantics as fetch_summary_merged.
|
|
100
|
+
def fetch_daily_merged(api_keys, days:, model: nil)
|
|
101
|
+
dailies = Array(api_keys).uniq.map { |key| fetch_daily(key, days: days, model: model) }.compact
|
|
102
|
+
return nil if dailies.empty?
|
|
103
|
+
|
|
104
|
+
{ days: merge_daily_entries(dailies.flat_map { |daily| daily[:days] }) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fetch authoritative usage summary for the platform (openclacky).
|
|
108
|
+
# Returns a hash shaped like BillingStore#summary, or nil on failure.
|
|
109
|
+
def fetch_summary(api_key, period:, model: nil)
|
|
110
|
+
path = "/api/v1/usage/summary?period=#{period}"
|
|
111
|
+
path += "&model=#{URI.encode_www_form_component(model)}" if model && !model.empty?
|
|
112
|
+
|
|
113
|
+
data = request(api_key, path)
|
|
114
|
+
data && normalize_summary(data)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Fetch authoritative daily breakdown for the platform (openclacky).
|
|
118
|
+
# Returns { days: [...] } or nil on failure.
|
|
119
|
+
def fetch_daily(api_key, days:, model: nil)
|
|
120
|
+
path = "/api/v1/usage/daily?days=#{days}"
|
|
121
|
+
path += "&model=#{URI.encode_www_form_component(model)}" if model && !model.empty?
|
|
122
|
+
|
|
123
|
+
data = request(api_key, path)
|
|
124
|
+
return nil unless data.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
days_data = data["days"] || data[:days] || []
|
|
127
|
+
days_data = days_data.map do |d|
|
|
128
|
+
next d unless d.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
align_prompt_tokens(d.transform_keys(&:to_sym))
|
|
131
|
+
end
|
|
132
|
+
{ days: days_data }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private def request(api_key, path)
|
|
136
|
+
client = Clacky::PlatformHttpClient.new
|
|
137
|
+
result = client.get(path, headers: { "Authorization" => "Bearer #{api_key}" })
|
|
138
|
+
result[:success] ? result[:data] : nil
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private def merge_summaries(a, b)
|
|
144
|
+
merged = {}
|
|
145
|
+
(a.keys | b.keys).each do |key|
|
|
146
|
+
merged[key] =
|
|
147
|
+
case key
|
|
148
|
+
when :by_model then merge_by_model(a[:by_model], b[:by_model])
|
|
149
|
+
when :by_day then merge_by_day(a[:by_day], b[:by_day])
|
|
150
|
+
else merge_value(a[key], b[key])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
merged
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private def merge_by_model(a, b)
|
|
157
|
+
merged = {}
|
|
158
|
+
(a || {}).each { |model, entry| merged[model] = entry }
|
|
159
|
+
(b || {}).each do |model, entry|
|
|
160
|
+
merged[model] = merged.key?(model) ? merge_entries(merged[model], entry) : entry
|
|
161
|
+
end
|
|
162
|
+
merged
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private def merge_by_day(a, b)
|
|
166
|
+
merged = {}
|
|
167
|
+
(a || {}).each { |date, cost| merged[date] = cost.to_f }
|
|
168
|
+
(b || {}).each { |date, cost| merged[date] = (merged[date] || 0.0) + cost.to_f }
|
|
169
|
+
merged
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private def merge_daily_entries(entries)
|
|
173
|
+
by_date = {}
|
|
174
|
+
entries.each do |entry|
|
|
175
|
+
next unless entry.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
date = entry[:date] || entry["date"]
|
|
178
|
+
next if date.nil?
|
|
179
|
+
|
|
180
|
+
by_date[date] = by_date.key?(date) ? merge_entries(by_date[date], entry) : entry
|
|
181
|
+
end
|
|
182
|
+
by_date.values.sort_by { |entry| entry[:date].to_s }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private def merge_entries(a, b)
|
|
186
|
+
merged = {}
|
|
187
|
+
(a.keys | b.keys).each { |key| merged[key] = merge_value(a[key], b[key]) }
|
|
188
|
+
merged
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Sum numeric leaf fields. Non-numeric fields (period, from/to,
|
|
192
|
+
# source) are identical across keys — they come from the same query
|
|
193
|
+
# shape — so the first value wins.
|
|
194
|
+
private def merge_value(a, b)
|
|
195
|
+
return b if a.nil?
|
|
196
|
+
return a if b.nil?
|
|
197
|
+
return a + b if a.is_a?(Numeric) && b.is_a?(Numeric)
|
|
198
|
+
|
|
199
|
+
a
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Normalise the platform summary (string keys) to the same shape as
|
|
203
|
+
# BillingStore#summary (symbol keys, symbol-keyed by_model entries).
|
|
204
|
+
private def normalize_summary(data)
|
|
205
|
+
return nil unless data.is_a?(Hash)
|
|
206
|
+
|
|
207
|
+
normalized = data.transform_keys(&:to_sym)
|
|
208
|
+
if normalized[:by_model].is_a?(Hash)
|
|
209
|
+
normalized[:by_model] = normalized[:by_model].transform_values do |entry|
|
|
210
|
+
entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : entry
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
align_prompt_tokens(normalized)
|
|
214
|
+
normalized
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The platform reports prompt_tokens as Anthropic's input_tokens (the
|
|
218
|
+
# post-cache tail), while local records store prompt_tokens with
|
|
219
|
+
# cache_read already folded in. Fold cache_read back so prompt/total
|
|
220
|
+
# line up and the UI's `prompt - cache_read` never goes negative.
|
|
221
|
+
private def align_prompt_tokens(entry)
|
|
222
|
+
cache_read = entry[:cache_read_tokens].to_i
|
|
223
|
+
entry[:prompt_tokens] = entry[:prompt_tokens].to_i + cache_read
|
|
224
|
+
entry[:total_tokens] = entry[:total_tokens].to_i + cache_read if entry.key?(:total_tokens)
|
|
225
|
+
entry[:tokens] = entry[:tokens].to_i + cache_read if entry.key?(:tokens)
|
|
226
|
+
entry
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -912,14 +912,15 @@ module Clacky
|
|
|
912
912
|
{ success: false, error: e.message }
|
|
913
913
|
end
|
|
914
914
|
|
|
915
|
-
# Synchronise brand extensions in the background for activated
|
|
915
|
+
# Synchronise brand extensions in the background for activated consumer
|
|
916
|
+
# installs. Brand administrators manage extensions through ext-studio and
|
|
917
|
+
# must not auto-install the extensions they publish for consumers.
|
|
916
918
|
# Mirrors sync_brand_skills_async! but installs into the extension layer.
|
|
917
|
-
# Unlike brand skills, new extensions are auto-installed because a bundled
|
|
918
|
-
# extension is chosen by the brand administrator, not the end user.
|
|
919
919
|
#
|
|
920
920
|
# @return [Thread, nil]
|
|
921
921
|
def sync_brand_extensions_async!(on_complete: nil)
|
|
922
922
|
return nil unless activated?
|
|
923
|
+
return nil if user_licensed?
|
|
923
924
|
return nil if ENV["CLACKY_TEST"] == "1"
|
|
924
925
|
|
|
925
926
|
Clacky::ThreadRegistry.spawn(name: "brand-fetch-extensions") do
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# A one-way, thread-safe cancellation flag shared across a parent agent and
|
|
5
|
+
# the subagents it forks. Fan-out runs each subagent on its own worker thread;
|
|
6
|
+
# when the parent is interrupted the worker threads must learn to stop, but
|
|
7
|
+
# they cannot see the parent's thread-local task epoch. Flipping this flag is
|
|
8
|
+
# the shared signal every subagent polls at its safe checkpoints.
|
|
9
|
+
class CancelFlag
|
|
10
|
+
def initialize
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@cancelled = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancel!
|
|
16
|
+
@mutex.synchronize { @cancelled = true }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def cancelled?
|
|
20
|
+
@mutex.synchronize { @cancelled }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -1349,8 +1349,8 @@ module Clacky
|
|
|
1349
1349
|
|
|
1350
1350
|
# ── Security gate ──────────────────────────────────────────────────────
|
|
1351
1351
|
# Binding to 0.0.0.0 exposes the server to the public network.
|
|
1352
|
-
# Refuse to start unless
|
|
1353
|
-
if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY")
|
|
1352
|
+
# Refuse to start unless an access key is available.
|
|
1353
|
+
if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY") && Clacky::AccessKey.from_file.nil?
|
|
1354
1354
|
puts <<~MSG
|
|
1355
1355
|
╔══════════════════════════════════════════════════════════════╗
|
|
1356
1356
|
║ ⚠️ Security Warning: Refusing to start ║
|
|
@@ -1365,6 +1365,8 @@ module Clacky
|
|
|
1365
1365
|
║ Then export it: ║
|
|
1366
1366
|
║ export CLACKY_ACCESS_KEY=<your-generated-key> ║
|
|
1367
1367
|
║ ║
|
|
1368
|
+
║ Or write it to ~/.clacky/access_key ║
|
|
1369
|
+
║ ║
|
|
1368
1370
|
╚══════════════════════════════════════════════════════════════╝
|
|
1369
1371
|
MSG
|
|
1370
1372
|
exit(1)
|
data/lib/clacky/client.rb
CHANGED
|
@@ -44,6 +44,7 @@ module Clacky
|
|
|
44
44
|
effective_api_format ||= "anthropic-messages" if anthropic_format
|
|
45
45
|
resolved_type = Providers.api_type_for_model(provider_id, @model, user_override: effective_api_format)
|
|
46
46
|
@use_anthropic_format = resolved_type == "anthropic-messages"
|
|
47
|
+
@use_responses_format = resolved_type == "openai-responses"
|
|
47
48
|
|
|
48
49
|
# Remember the provider id so we can tune connection headers below
|
|
49
50
|
# (OpenRouter's /v1/messages accepts either Bearer or x-api-key, but
|
|
@@ -66,6 +67,12 @@ module Clacky
|
|
|
66
67
|
@use_anthropic_format && !@use_bedrock
|
|
67
68
|
end
|
|
68
69
|
|
|
70
|
+
# Returns true when the client talks to the OpenAI Responses API
|
|
71
|
+
# (/v1/responses) instead of Chat Completions.
|
|
72
|
+
def responses_format?(model = nil)
|
|
73
|
+
@use_responses_format && !@use_bedrock
|
|
74
|
+
end
|
|
75
|
+
|
|
69
76
|
# ── Connection test ───────────────────────────────────────────────────────
|
|
70
77
|
|
|
71
78
|
# Test API connection by sending a minimal request.
|
|
@@ -81,6 +88,11 @@ module Clacky
|
|
|
81
88
|
minimal_body = { model: api_model, max_tokens: 16,
|
|
82
89
|
messages: [{ role: "user", content: "hi" }] }.to_json
|
|
83
90
|
response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = minimal_body }
|
|
91
|
+
elsif responses_format?
|
|
92
|
+
minimal_body = MessageFormat::OpenAIResponses.build_request_body(
|
|
93
|
+
[{ role: "user", content: "hi" }], api_model, [], 16, false
|
|
94
|
+
).to_json
|
|
95
|
+
response = openai_connection.post("responses") { |r| r.body = minimal_body }
|
|
84
96
|
else
|
|
85
97
|
minimal_body = { model: api_model, max_tokens: 16,
|
|
86
98
|
messages: [{ role: "user", content: "hi" }] }.to_json
|
|
@@ -113,6 +125,10 @@ module Clacky
|
|
|
113
125
|
body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
|
|
114
126
|
response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
|
|
115
127
|
parse_simple_anthropic_response(response)
|
|
128
|
+
elsif responses_format?
|
|
129
|
+
body = MessageFormat::OpenAIResponses.build_request_body(messages, api_model, [], max_tokens, false)
|
|
130
|
+
response = openai_connection.post("responses") { |r| r.body = body.to_json }
|
|
131
|
+
parse_simple_openai_responses_response(response)
|
|
116
132
|
else
|
|
117
133
|
body = MessageFormat::OpenAI.build_request_body(messages, api_model, [], max_tokens, false, reasoning_effort: reasoning_effort)
|
|
118
134
|
response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
|
|
@@ -164,6 +180,9 @@ module Clacky
|
|
|
164
180
|
elsif anthropic_format?
|
|
165
181
|
streaming_used = !on_chunk.nil?
|
|
166
182
|
send_anthropic_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
|
|
183
|
+
elsif responses_format?
|
|
184
|
+
streaming_used = !on_chunk.nil?
|
|
185
|
+
send_openai_responses_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
|
|
167
186
|
else
|
|
168
187
|
streaming_used = !on_chunk.nil?
|
|
169
188
|
send_openai_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
|
|
@@ -206,6 +225,8 @@ module Clacky
|
|
|
206
225
|
MessageFormat::Bedrock.format_tool_results(response, tool_results)
|
|
207
226
|
elsif anthropic_format?
|
|
208
227
|
MessageFormat::Anthropic.format_tool_results(response, tool_results)
|
|
228
|
+
elsif responses_format?
|
|
229
|
+
MessageFormat::OpenAIResponses.format_tool_results(response, tool_results)
|
|
209
230
|
else
|
|
210
231
|
MessageFormat::OpenAI.format_tool_results(response, tool_results)
|
|
211
232
|
end
|
|
@@ -460,6 +481,92 @@ module Clacky
|
|
|
460
481
|
content
|
|
461
482
|
end
|
|
462
483
|
|
|
484
|
+
# ── OpenAI Responses API request / response ───────────────────────────────
|
|
485
|
+
|
|
486
|
+
def send_openai_responses_request(messages, model, tools, max_tokens, caching_enabled,
|
|
487
|
+
reasoning_effort: nil, on_chunk: nil, capability_model: nil)
|
|
488
|
+
# Override max_tokens when the model declares a higher output ceiling
|
|
489
|
+
model_for_limit = capability_model || model
|
|
490
|
+
model_limit = Providers.max_output_for(model_for_limit)
|
|
491
|
+
max_tokens = model_limit if model_limit
|
|
492
|
+
|
|
493
|
+
# Deliberately no apply_message_caching here: the Responses API does
|
|
494
|
+
# not recognize Anthropic-style cache_control markers, and OpenAI's
|
|
495
|
+
# Responses prompt caching is automatic server-side. Injecting
|
|
496
|
+
# cache_control would be silently ignored (or rejected by stricter
|
|
497
|
+
# endpoints).
|
|
498
|
+
|
|
499
|
+
cap_model = capability_model || model
|
|
500
|
+
body = MessageFormat::OpenAIResponses.build_request_body(
|
|
501
|
+
messages, model, tools, max_tokens, caching_enabled,
|
|
502
|
+
vision_supported: Providers.supports?(@provider_id, :vision, model_name: cap_model),
|
|
503
|
+
reasoning_effort: reasoning_effort
|
|
504
|
+
)
|
|
505
|
+
return send_openai_responses_stream_request(body, on_chunk) if on_chunk
|
|
506
|
+
|
|
507
|
+
response = openai_connection.post("responses") { |r| r.body = body.to_json }
|
|
508
|
+
|
|
509
|
+
raise_error(response) unless response.status == 200
|
|
510
|
+
check_html_response(response)
|
|
511
|
+
|
|
512
|
+
parsed_body = safe_json_parse(response.body, context: "LLM response")
|
|
513
|
+
MessageFormat::OpenAIResponses.parse_response(parsed_body)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Streaming variant for the OpenAI Responses API.
|
|
517
|
+
# Posts to the "responses" endpoint with stream:true; the upstream returns
|
|
518
|
+
# typed SSE events (response.output_text.delta,
|
|
519
|
+
# response.function_call_arguments.delta, response.completed, etc.) that
|
|
520
|
+
# the aggregator reassembles into the non-streaming response shape.
|
|
521
|
+
private def send_openai_responses_stream_request(body, on_chunk)
|
|
522
|
+
stream_body = body.merge(stream: true)
|
|
523
|
+
aggregator = OpenAIResponsesStreamAggregator.new(on_chunk: on_chunk)
|
|
524
|
+
sse_buf = +""
|
|
525
|
+
|
|
526
|
+
response = openai_connection.post("responses") do |req|
|
|
527
|
+
req.headers["Accept"] = "text/event-stream"
|
|
528
|
+
req.body = stream_body.to_json
|
|
529
|
+
req.options.on_data = proc do |chunk, _bytes_received, _env|
|
|
530
|
+
sse_buf << chunk
|
|
531
|
+
drain_sse_frames(sse_buf) { |_event, data| aggregator.handle(data) }
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
unless response.status == 200
|
|
536
|
+
response.env.body = sse_buf if response.body.to_s.empty?
|
|
537
|
+
raise_error(response)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
result = aggregator.to_h
|
|
541
|
+
log_stream_summary("openai-responses", aggregator, aggregator.saw_done? ? "completed" : nil)
|
|
542
|
+
# A complete Responses API stream always terminates with a
|
|
543
|
+
# response.completed / response.done (or response.incomplete) event.
|
|
544
|
+
# Its absence means the upstream cut the stream mid-response; retry
|
|
545
|
+
# rather than accept a silently truncated answer.
|
|
546
|
+
unless aggregator.saw_done?
|
|
547
|
+
raise Clacky::UpstreamTruncatedError,
|
|
548
|
+
"[LLM] Streaming response ended without response.completed (upstream cut the stream). Retrying..."
|
|
549
|
+
end
|
|
550
|
+
MessageFormat::OpenAIResponses.parse_response(result)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def parse_simple_openai_responses_response(response)
|
|
554
|
+
raise_error(response) unless response.status == 200
|
|
555
|
+
parsed_body = safe_json_parse(response.body, context: "LLM response")
|
|
556
|
+
result = MessageFormat::OpenAIResponses.parse_response(parsed_body)
|
|
557
|
+
content = result[:content]
|
|
558
|
+
if content.nil?
|
|
559
|
+
snippet = response.body.to_s[0, 1200]
|
|
560
|
+
if defined?(Clacky::Logger)
|
|
561
|
+
Clacky::Logger.warn("[parse_simple_openai_responses_response] no content. status=#{response.status} body=#{snippet}")
|
|
562
|
+
end
|
|
563
|
+
raise RetryableError,
|
|
564
|
+
"Upstream Responses API response missing text content. " \
|
|
565
|
+
"Body snippet: #{snippet}"
|
|
566
|
+
end
|
|
567
|
+
content
|
|
568
|
+
end
|
|
569
|
+
|
|
463
570
|
# ── Prompt caching helpers ────────────────────────────────────────────────
|
|
464
571
|
|
|
465
572
|
# Add cache_control markers to the last 2 messages in the array.
|
|
@@ -7,29 +7,35 @@ choose from.
|
|
|
7
7
|
|
|
8
8
|
## Output format (strict)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
Respond with ONLY a JSON array of exactly 3 objects. No prose before or after,
|
|
11
|
+
no markdown code fence:
|
|
12
|
+
|
|
13
|
+
[{"action": "...", "reason": "..."}, {"action": "...", "reason": "..."}, {"action": "...", "reason": "..."}]
|
|
14
|
+
|
|
15
|
+
- action: a complete, ready-to-send instruction for the agent — the user
|
|
16
|
+
should be able to paste it into the input box and send it as-is, no
|
|
17
|
+
editing. Write it in the USER's voice, speaking TO the agent (e.g. "Run
|
|
18
|
+
the test suite and show me the failures"), never in the agent's voice
|
|
19
|
+
speaking to the user (e.g. "Tell me what you'd like to do" is wrong — the
|
|
20
|
+
user would be asking the agent to ask them). Put NOTHING here but the
|
|
21
|
+
instruction itself: no leading dash, no rationale, no trailing commentary.
|
|
22
|
+
- reason: one short sentence (under ~20 words) explaining why you suggest it.
|
|
18
23
|
- The 3 options must be *mutually distinct directions* — e.g. one continue /
|
|
19
24
|
fix path, one verification path, one wrap-up path. Do not rephrase the same
|
|
20
25
|
idea three times.
|
|
21
|
-
- If no reasonable options exist at all,
|
|
26
|
+
- If no reasonable options exist at all, respond with an empty array: []
|
|
22
27
|
|
|
23
28
|
## Hard rules
|
|
24
29
|
|
|
25
30
|
1. Base every option ONLY on facts present in the brief. Never invent file
|
|
26
31
|
names, tasks, or project content that the brief does not mention.
|
|
27
32
|
2. When the user's latest message is vague and there were no tool calls
|
|
28
|
-
(conversation just started, e.g. "hi"):
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
the brief shows
|
|
32
|
-
target.
|
|
33
|
+
(conversation just started, e.g. "hi"): offer low-risk openers the user
|
|
34
|
+
could send right now, grounded in what the brief actually shows — e.g.
|
|
35
|
+
"Summarize what this project does and where its entry points are" when the
|
|
36
|
+
brief names a project. If the brief shows nothing to work from, respond
|
|
37
|
+
with an empty array rather than inventing an exploration target. Never
|
|
38
|
+
turn an option into a question aimed at the user.
|
|
33
39
|
3. When the brief shows a clear task in progress (recent tool calls, files
|
|
34
40
|
written, errors, tests), recommend directions tied to THAT task: continue
|
|
35
41
|
the work, verify it (run the relevant tests), or wrap it up (review the
|
|
@@ -38,9 +44,9 @@ Output exactly 3 options, one per line, in this format:
|
|
|
38
44
|
|
|
39
45
|
## Judgement guide
|
|
40
46
|
|
|
41
|
-
1. Conversation just started, no tool calls →
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
1. Conversation just started, no tool calls → suggest grounded openers the
|
|
48
|
+
user can send as-is (only if the brief shows actual project content);
|
|
49
|
+
otherwise return [].
|
|
44
50
|
2. Brief shows errors or tool failures → include a fix/continue option.
|
|
45
51
|
3. Files were written but no tests have run → include a verification option.
|
|
46
52
|
4. Task looks complete → include a wrap-up option (review diff, run the full
|
|
@@ -29,7 +29,25 @@ module Clacky
|
|
|
29
29
|
CONVERSATION_TURNS = 8
|
|
30
30
|
SUMMARY_LIMIT = 200
|
|
31
31
|
MESSAGE_LIMIT = 200
|
|
32
|
-
|
|
32
|
+
MAX_OPTIONS = 3
|
|
33
|
+
ACTION_LIMIT = 500
|
|
34
|
+
REASON_LIMIT = 200
|
|
35
|
+
|
|
36
|
+
# Models copy the panel's own " · " separator when they drop the JSON
|
|
37
|
+
# format. Deliberately structural: a localized "Reason:" label would need
|
|
38
|
+
# one pattern per language, so those lines keep the label inside the action.
|
|
39
|
+
FALLBACK_REASON_SEPARATOR = /\s+·\s+/.freeze
|
|
40
|
+
|
|
41
|
+
# Some providers keep thinking inline in `content` (e.g. MiniMax), and the
|
|
42
|
+
# closing tag is missing whenever the reply gets cut short.
|
|
43
|
+
THINK_BLOCK = %r{<think>.*?</think>}m.freeze
|
|
44
|
+
UNCLOSED_THINK = /<think>[\s\S]*\z/.freeze
|
|
45
|
+
|
|
46
|
+
# A degraded line only counts as an option when the model marked it up as
|
|
47
|
+
# one: a list bullet or a [action] wrapper. Without this, reasoning prose
|
|
48
|
+
# turns into three bogus suggestions.
|
|
49
|
+
FALLBACK_LIST_MARKER = /\A(?:[-*•]|\d+[.)])\s+/.freeze
|
|
50
|
+
FALLBACK_BRACKETED = /\A\[([^\]]+)\]\s*(.*)\z/m.freeze
|
|
33
51
|
class << self
|
|
34
52
|
def enabled_for?(agent)
|
|
35
53
|
return false if agent.instance_variable_get(:@is_subagent)
|
|
@@ -137,14 +155,16 @@ module Clacky
|
|
|
137
155
|
private def analyze(snapshot)
|
|
138
156
|
cfg = Advisor.config_for(@agent)
|
|
139
157
|
advice = generate_advice(cfg, snapshot)
|
|
158
|
+
options = parse_options(advice)
|
|
140
159
|
Clacky::Logger.info("[Advisor] analyze",
|
|
141
160
|
session: @agent.session_id.to_s,
|
|
142
161
|
len: advice.length,
|
|
162
|
+
options: options.size,
|
|
143
163
|
head: advice[0, 100].to_s)
|
|
144
|
-
if
|
|
164
|
+
if options.empty?
|
|
145
165
|
@agent.emit_event("ext.advisor.done", reason: "empty")
|
|
146
166
|
else
|
|
147
|
-
push_advice(
|
|
167
|
+
push_advice(options)
|
|
148
168
|
end
|
|
149
169
|
rescue StandardError => e
|
|
150
170
|
warn_error("analyze", e)
|
|
@@ -217,8 +237,87 @@ module Clacky
|
|
|
217
237
|
nil
|
|
218
238
|
end
|
|
219
239
|
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
# Turn the raw model reply into [{ action:, reason: }, ...]. The JSON path
|
|
241
|
+
# is authoritative; the line-based path exists because weak models still
|
|
242
|
+
# drift out of the requested format, and a rough clickable option beats
|
|
243
|
+
# showing nothing.
|
|
244
|
+
private def parse_options(raw)
|
|
245
|
+
text = strip_thinking(raw.to_s).strip
|
|
246
|
+
return [] if text.empty?
|
|
247
|
+
|
|
248
|
+
array = extract_json_array(text)
|
|
249
|
+
return normalize_options(array) if array
|
|
250
|
+
|
|
251
|
+
fallback_options(text)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private def strip_thinking(text)
|
|
255
|
+
text.gsub(THINK_BLOCK, "").sub(UNCLOSED_THINK, "")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private def extract_json_array(text)
|
|
259
|
+
body = text
|
|
260
|
+
body = body.gsub(/\A```[a-zA-Z]*\n?/, "").gsub(/```\z/, "").strip if body.start_with?("```")
|
|
261
|
+
parsed = begin
|
|
262
|
+
JSON.parse(body)
|
|
263
|
+
rescue JSON::ParserError
|
|
264
|
+
# Models still wrap the array in a sentence or a stray code fence.
|
|
265
|
+
match = body.match(/\[[\s\S]*\]/)
|
|
266
|
+
match && begin
|
|
267
|
+
JSON.parse(match[0])
|
|
268
|
+
rescue JSON::ParserError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
parsed.is_a?(Array) ? parsed : nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
private def normalize_options(array)
|
|
276
|
+
options = []
|
|
277
|
+
array.each do |entry|
|
|
278
|
+
next unless entry.is_a?(Hash)
|
|
279
|
+
|
|
280
|
+
action = entry["action"].to_s.strip
|
|
281
|
+
next if action.empty?
|
|
282
|
+
|
|
283
|
+
options << { action: action[0, ACTION_LIMIT], reason: entry["reason"].to_s.strip[0, REASON_LIMIT] }
|
|
284
|
+
break if options.size >= MAX_OPTIONS
|
|
285
|
+
end
|
|
286
|
+
options
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# One option per line, splitting the reason off so it never leaks into
|
|
290
|
+
# `action` — that text is sent verbatim to the agent when clicked. Only
|
|
291
|
+
# list items qualify, so a reply that is plain prose yields nothing.
|
|
292
|
+
private def fallback_options(text)
|
|
293
|
+
options = []
|
|
294
|
+
text.split("\n").each do |line|
|
|
295
|
+
action, reason = split_fallback_line(line)
|
|
296
|
+
next if action.empty?
|
|
297
|
+
|
|
298
|
+
options << { action: action[0, ACTION_LIMIT], reason: reason[0, REASON_LIMIT] }
|
|
299
|
+
break if options.size >= MAX_OPTIONS
|
|
300
|
+
end
|
|
301
|
+
options
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private def split_fallback_line(line)
|
|
305
|
+
stripped = line.strip
|
|
306
|
+
listed = FALLBACK_LIST_MARKER.match(stripped)
|
|
307
|
+
body = listed ? stripped.sub(FALLBACK_LIST_MARKER, "") : stripped
|
|
308
|
+
return ["", ""] if body.empty?
|
|
309
|
+
|
|
310
|
+
bracketed = body.match(FALLBACK_BRACKETED)
|
|
311
|
+
return [bracketed[1].strip, bracketed[2].strip] if bracketed
|
|
312
|
+
|
|
313
|
+
return listed ? [body, ""] : ["", ""] unless FALLBACK_REASON_SEPARATOR.match(body)
|
|
314
|
+
|
|
315
|
+
action, reason = body.split(FALLBACK_REASON_SEPARATOR, 2)
|
|
316
|
+
[action.to_s.strip, reason.to_s.strip]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private def push_advice(options)
|
|
320
|
+
@agent.emit_event("ext.advisor.recommendations", options: options)
|
|
222
321
|
end
|
|
223
322
|
|
|
224
323
|
private def advisor_model_name(cfg)
|