openclacky 1.2.8 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent_config.rb +91 -7
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/providers.rb +82 -0
- data/lib/clacky/server/http_server.rb +210 -20
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +172 -12
- data/lib/clacky/web/i18n.js +58 -0
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +7 -2
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Media
|
|
9
|
+
# OpenAI-compatible image generation provider.
|
|
10
|
+
#
|
|
11
|
+
# Talks to POST <base_url>/images/generations with the standard OpenAI
|
|
12
|
+
# request shape. Handles three providers under one class because they
|
|
13
|
+
# all expose the same endpoint: OpenAI, OpenRouter, and the openclacky
|
|
14
|
+
# platform gateway. Provider-specific quirks (model id naming, billing)
|
|
15
|
+
# live in PRESETS, not here.
|
|
16
|
+
class OpenAICompat < Base
|
|
17
|
+
ASPECT_TO_SIZE = {
|
|
18
|
+
"landscape" => "1536x1024",
|
|
19
|
+
"square" => "1024x1024",
|
|
20
|
+
"portrait" => "1024x1536"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_ASPECT = "landscape"
|
|
24
|
+
|
|
25
|
+
def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
|
|
26
|
+
provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
|
|
27
|
+
aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
|
|
28
|
+
size = ASPECT_TO_SIZE[aspect]
|
|
29
|
+
|
|
30
|
+
if prompt.to_s.strip.empty?
|
|
31
|
+
return error_response(
|
|
32
|
+
error: "Prompt is required and must be a non-empty string",
|
|
33
|
+
error_type: "invalid_argument",
|
|
34
|
+
provider: provider_id,
|
|
35
|
+
aspect_ratio: aspect
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if @api_key.to_s.empty?
|
|
40
|
+
return error_response(
|
|
41
|
+
error: "api_key not configured for image model '#{@model}'",
|
|
42
|
+
error_type: "auth_required",
|
|
43
|
+
provider: provider_id,
|
|
44
|
+
prompt: prompt,
|
|
45
|
+
aspect_ratio: aspect
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
payload = { model: @model, n: n }
|
|
50
|
+
if gemini_family?(@model)
|
|
51
|
+
# Gemini image models (routed via openclacky / openrouter gateway)
|
|
52
|
+
# don't accept the OpenAI `size` parameter — they infer aspect from
|
|
53
|
+
# the prompt text. Embedding a hint keeps the user's aspect choice
|
|
54
|
+
# honoured without breaking the gateway request validator.
|
|
55
|
+
payload[:prompt] = "#{prompt}\n\n[aspect: #{aspect}]"
|
|
56
|
+
else
|
|
57
|
+
payload[:prompt] = prompt
|
|
58
|
+
payload[:size] = size
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
response = connection.post("images/generations") do |req|
|
|
63
|
+
req.headers["Content-Type"] = "application/json"
|
|
64
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
65
|
+
req.body = JSON.generate(payload)
|
|
66
|
+
end
|
|
67
|
+
rescue Faraday::Error => e
|
|
68
|
+
return error_response(
|
|
69
|
+
error: "HTTP request failed: #{e.message}",
|
|
70
|
+
error_type: "network_error",
|
|
71
|
+
provider: provider_id,
|
|
72
|
+
prompt: prompt,
|
|
73
|
+
aspect_ratio: aspect
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless response.success?
|
|
78
|
+
return error_response(
|
|
79
|
+
error: "Upstream #{response.status}: #{truncate(response.body, 500)}",
|
|
80
|
+
error_type: "api_error",
|
|
81
|
+
provider: provider_id,
|
|
82
|
+
prompt: prompt,
|
|
83
|
+
aspect_ratio: aspect
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
body = parse_json(response.body)
|
|
88
|
+
return error_response(
|
|
89
|
+
error: "Invalid JSON response from upstream",
|
|
90
|
+
error_type: "invalid_response",
|
|
91
|
+
provider: provider_id,
|
|
92
|
+
prompt: prompt,
|
|
93
|
+
aspect_ratio: aspect
|
|
94
|
+
) unless body.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
data = body["data"] || []
|
|
97
|
+
first = data.first
|
|
98
|
+
if first.nil?
|
|
99
|
+
return error_response(
|
|
100
|
+
error: "Upstream returned no image data",
|
|
101
|
+
error_type: "empty_response",
|
|
102
|
+
provider: provider_id,
|
|
103
|
+
prompt: prompt,
|
|
104
|
+
aspect_ratio: aspect
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
image_ref =
|
|
109
|
+
if first["b64_json"]
|
|
110
|
+
save_b64_image(first["b64_json"], output_dir: output_dir || Dir.pwd, prefix: "img")
|
|
111
|
+
elsif first["url"]
|
|
112
|
+
first["url"]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if image_ref.nil?
|
|
116
|
+
return error_response(
|
|
117
|
+
error: "Response contained neither b64_json nor url",
|
|
118
|
+
error_type: "empty_response",
|
|
119
|
+
provider: provider_id,
|
|
120
|
+
prompt: prompt,
|
|
121
|
+
aspect_ratio: aspect
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
success_response(
|
|
126
|
+
image: image_ref,
|
|
127
|
+
prompt: prompt,
|
|
128
|
+
aspect_ratio: aspect,
|
|
129
|
+
provider: provider_id,
|
|
130
|
+
extra: {
|
|
131
|
+
"size" => size,
|
|
132
|
+
"usage" => body["usage"],
|
|
133
|
+
"cost_usd" => body["cost_usd"]
|
|
134
|
+
}.compact
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private def connection
|
|
139
|
+
Faraday.new(url: normalized_base_url) do |f|
|
|
140
|
+
f.options.timeout = 240
|
|
141
|
+
f.options.open_timeout = 10
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private def gemini_family?(model_name)
|
|
146
|
+
model_name.to_s.match?(/gemini|imagen/i)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# base_url is taken verbatim from PRESETS (each provider already
|
|
150
|
+
# includes the API version segment when needed). We only ensure a
|
|
151
|
+
# trailing slash so Faraday's relative-path join behaves.
|
|
152
|
+
private def normalized_base_url
|
|
153
|
+
"#{@base_url.to_s.chomp("/")}/"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private def parse_json(body)
|
|
157
|
+
JSON.parse(body)
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private def truncate(str, max)
|
|
163
|
+
s = str.to_s
|
|
164
|
+
s.length > max ? "#{s[0, max]}..." : s
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -41,6 +41,17 @@ module Clacky
|
|
|
41
41
|
"dsk-deepseek-v4-flash",
|
|
42
42
|
"or-gemini-3-1-pro"
|
|
43
43
|
],
|
|
44
|
+
# Image generation models served by the openclacky platform
|
|
45
|
+
# gateway. The gateway exposes a standard OpenAI-compatible
|
|
46
|
+
# /v1/images/generations endpoint, so the same OpenAICompat
|
|
47
|
+
# provider class handles them. `or-` prefix mirrors the chat
|
|
48
|
+
# model naming — these are routed through the OpenRouter
|
|
49
|
+
# backend by the platform.
|
|
50
|
+
"image_models" => [
|
|
51
|
+
"or-gemini-3-pro-image",
|
|
52
|
+
"or-gpt-image-2"
|
|
53
|
+
],
|
|
54
|
+
"default_image_model" => "or-gpt-image-2",
|
|
44
55
|
# Provider-level default: the Claude family served here is vision-capable.
|
|
45
56
|
"capabilities" => { "vision" => true }.freeze,
|
|
46
57
|
# Model-level overrides: DeepSeek models routed through this provider
|
|
@@ -123,6 +134,13 @@ module Clacky
|
|
|
123
134
|
/\Aanthropic\// => "anthropic-messages",
|
|
124
135
|
/\Aclaude[-.]/ => "anthropic-messages"
|
|
125
136
|
}.freeze,
|
|
137
|
+
# Image generation via OpenRouter is currently routed through the
|
|
138
|
+
# openclacky platform gateway (see "openclacky" provider above) which
|
|
139
|
+
# handles the OpenRouter chat-completions + modalities translation.
|
|
140
|
+
# Direct OpenRouter image config is not exposed here — leave empty
|
|
141
|
+
# until we ship a dedicated client-side adapter for that protocol.
|
|
142
|
+
"image_models" => [],
|
|
143
|
+
"default_image_model" => nil,
|
|
126
144
|
"website_url" => "https://openrouter.ai/keys"
|
|
127
145
|
}.freeze,
|
|
128
146
|
|
|
@@ -305,6 +323,12 @@ module Clacky
|
|
|
305
323
|
"gpt-5.5" => "gpt-5.4-mini",
|
|
306
324
|
"gpt-5.4" => "gpt-5.4-mini"
|
|
307
325
|
},
|
|
326
|
+
# OpenAI's image generation model — same /v1/images/generations
|
|
327
|
+
# endpoint, so the OpenAICompat image provider handles it.
|
|
328
|
+
"image_models" => [
|
|
329
|
+
"gpt-image-2"
|
|
330
|
+
],
|
|
331
|
+
"default_image_model" => "gpt-image-2",
|
|
308
332
|
"website_url" => "https://platform.openai.com/api-keys"
|
|
309
333
|
}.freeze,
|
|
310
334
|
|
|
@@ -342,6 +366,8 @@ module Clacky
|
|
|
342
366
|
|
|
343
367
|
}.freeze
|
|
344
368
|
|
|
369
|
+
MEDIA_KINDS = %w[image video audio].freeze
|
|
370
|
+
|
|
345
371
|
class << self
|
|
346
372
|
# Check if a provider preset exists
|
|
347
373
|
# @param provider_id [String] The provider identifier (e.g., "anthropic", "openrouter")
|
|
@@ -446,6 +472,62 @@ module Clacky
|
|
|
446
472
|
preset&.dig("models") || []
|
|
447
473
|
end
|
|
448
474
|
|
|
475
|
+
# Get available image generation models for a provider.
|
|
476
|
+
# Returns an empty array when the provider doesn't declare any —
|
|
477
|
+
# callers should treat that as "image generation not supported by this provider".
|
|
478
|
+
# @param provider_id [String] The provider identifier
|
|
479
|
+
# @return [Array<String>] List of image model names
|
|
480
|
+
def image_models(provider_id)
|
|
481
|
+
preset = PRESETS[provider_id]
|
|
482
|
+
preset&.dig("image_models") || []
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Video generation models — placeholder. No provider supports video
|
|
486
|
+
# via Clacky yet; once they do, declare "video_models" alongside
|
|
487
|
+
# "image_models" in the relevant PRESETS entry and this returns it.
|
|
488
|
+
def video_models(provider_id)
|
|
489
|
+
preset = PRESETS[provider_id]
|
|
490
|
+
preset&.dig("video_models") || []
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Audio generation models — same placeholder pattern as video_models.
|
|
494
|
+
def audio_models(provider_id)
|
|
495
|
+
preset = PRESETS[provider_id]
|
|
496
|
+
preset&.dig("audio_models") || []
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Unified entry for media model lookup by kind.
|
|
500
|
+
# @param provider_id [String]
|
|
501
|
+
# @param kind [String] one of "image" / "video" / "audio"
|
|
502
|
+
# @return [Array<String>]
|
|
503
|
+
def media_models(provider_id, kind)
|
|
504
|
+
case kind.to_s
|
|
505
|
+
when "image" then image_models(provider_id)
|
|
506
|
+
when "video" then video_models(provider_id)
|
|
507
|
+
when "audio" then audio_models(provider_id)
|
|
508
|
+
else []
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Default media model for a kind under a provider. Falls back to the
|
|
513
|
+
# first declared model when no explicit default is set in the preset.
|
|
514
|
+
# Used by AgentConfig#derive_media_models! to pick which model to
|
|
515
|
+
# surface when the user is on "auto" mode.
|
|
516
|
+
def default_media_model(provider_id, kind)
|
|
517
|
+
preset = PRESETS[provider_id]
|
|
518
|
+
return nil unless preset
|
|
519
|
+
explicit = preset["default_#{kind}_model"]
|
|
520
|
+
return explicit if explicit
|
|
521
|
+
media_models(provider_id, kind).first
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The set of media kinds Clacky knows about. Drives UI rendering and
|
|
525
|
+
# derivation loops — adding a new modality means listing it here plus
|
|
526
|
+
# adding the corresponding generator class.
|
|
527
|
+
def media_kinds
|
|
528
|
+
MEDIA_KINDS
|
|
529
|
+
end
|
|
530
|
+
|
|
449
531
|
# Get the lite model for a provider.
|
|
450
532
|
# @param provider_id [String] The provider identifier
|
|
451
533
|
# @param primary_model [String, nil] The currently-selected primary model name.
|
|
@@ -370,6 +370,11 @@ module Clacky
|
|
|
370
370
|
30
|
|
371
371
|
elsif path.end_with?("/benchmark")
|
|
372
372
|
20
|
|
373
|
+
elsif path == "/api/media/image"
|
|
374
|
+
# Image generation routes through OpenRouter (chat completions
|
|
375
|
+
# with modalities:["image"]); end-to-end latency is commonly
|
|
376
|
+
# 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
|
|
377
|
+
300
|
|
373
378
|
else
|
|
374
379
|
10
|
|
375
380
|
end
|
|
@@ -399,6 +404,7 @@ module Clacky
|
|
|
399
404
|
when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
|
|
400
405
|
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
401
406
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
407
|
+
when ["GET", "/api/config/media"] then api_get_media_config(res)
|
|
402
408
|
when ["GET", "/api/providers"] then api_list_providers(res)
|
|
403
409
|
when ["GET", "/api/onboard/status"] then api_onboard_status(res)
|
|
404
410
|
when ["GET", "/api/browser/status"] then api_browser_status(res)
|
|
@@ -430,6 +436,8 @@ module Clacky
|
|
|
430
436
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
431
437
|
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
432
438
|
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
439
|
+
when ["POST", "/api/media/image"] then api_media_image(req, res)
|
|
440
|
+
when ["GET", "/api/media/types"] then api_media_types(res)
|
|
433
441
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
434
442
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
435
443
|
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
@@ -523,6 +531,9 @@ module Clacky
|
|
|
523
531
|
elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
524
532
|
id = path.sub("/api/config/models/", "")
|
|
525
533
|
api_delete_model(id, res)
|
|
534
|
+
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
|
|
535
|
+
kind = path.sub("/api/config/media/", "")
|
|
536
|
+
api_update_media_config(kind, req, res)
|
|
526
537
|
elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
|
|
527
538
|
name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
|
|
528
539
|
api_run_cron_task(name, res)
|
|
@@ -697,6 +708,158 @@ module Clacky
|
|
|
697
708
|
json_response(res, 500, { ok: false, error: e.message })
|
|
698
709
|
end
|
|
699
710
|
|
|
711
|
+
# POST /api/media/image
|
|
712
|
+
# Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
|
|
713
|
+
# "output_dir": "<absolute path, optional>" }
|
|
714
|
+
# Routes to the model configured with type=image in agent_config.
|
|
715
|
+
def api_media_image(req, res)
|
|
716
|
+
body = parse_json_body(req)
|
|
717
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
718
|
+
|
|
719
|
+
prompt = body["prompt"].to_s
|
|
720
|
+
if prompt.strip.empty?
|
|
721
|
+
return json_response(res, 422, { error: "prompt is required" })
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
aspect_ratio = body["aspect_ratio"].to_s
|
|
725
|
+
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
726
|
+
output_dir = body["output_dir"].to_s
|
|
727
|
+
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
728
|
+
|
|
729
|
+
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
730
|
+
prompt: prompt,
|
|
731
|
+
aspect_ratio: aspect_ratio,
|
|
732
|
+
output_dir: output_dir
|
|
733
|
+
)
|
|
734
|
+
if result["success"]
|
|
735
|
+
log_media_usage(result, prompt: prompt)
|
|
736
|
+
end
|
|
737
|
+
status = result["success"] ? 200 : 422
|
|
738
|
+
json_response(res, status, result)
|
|
739
|
+
rescue StandardError => e
|
|
740
|
+
json_response(res, 500, { error: e.message })
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
private def log_media_usage(result, prompt:)
|
|
744
|
+
usage = result["usage"]
|
|
745
|
+
cost = result["cost_usd"]
|
|
746
|
+
return if usage.nil? && cost.nil?
|
|
747
|
+
|
|
748
|
+
parts = []
|
|
749
|
+
parts << "model=#{result["model"]}"
|
|
750
|
+
parts << "provider=#{result["provider"]}"
|
|
751
|
+
if usage.is_a?(Hash)
|
|
752
|
+
parts << "prompt_tokens=#{usage["prompt_tokens"]}"
|
|
753
|
+
parts << "completion_tokens=#{usage["completion_tokens"]}"
|
|
754
|
+
parts << "cache_read=#{usage["cache_read_tokens"]}" if usage["cache_read_tokens"].to_i > 0
|
|
755
|
+
parts << "cache_write=#{usage["cache_write_tokens"]}" if usage["cache_write_tokens"].to_i > 0
|
|
756
|
+
end
|
|
757
|
+
parts << format("cost_usd=%.6f", cost.to_f) if cost
|
|
758
|
+
parts << "prompt=#{prompt[0, 60].inspect}"
|
|
759
|
+
Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# GET /api/media/types
|
|
763
|
+
# Returns which media types are configured in agent_config.models.
|
|
764
|
+
# Used by the media-gen skill to decide whether to surface generation
|
|
765
|
+
# capabilities to the user.
|
|
766
|
+
def api_media_types(res)
|
|
767
|
+
out = {}
|
|
768
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
769
|
+
state = @agent_config.media_state(t)
|
|
770
|
+
out[t] =
|
|
771
|
+
if state["configured"]
|
|
772
|
+
{
|
|
773
|
+
configured: true,
|
|
774
|
+
model: state["model"],
|
|
775
|
+
base_url: state["base_url"],
|
|
776
|
+
source: state["source"]
|
|
777
|
+
}
|
|
778
|
+
else
|
|
779
|
+
{ configured: false, source: "off" }
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
json_response(res, 200, out)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# GET /api/config/media
|
|
786
|
+
# Used by the Settings UI to render the tri-state media controls.
|
|
787
|
+
# Per-kind payload mirrors AgentConfig#media_state.
|
|
788
|
+
def api_get_media_config(res)
|
|
789
|
+
out = {}
|
|
790
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
791
|
+
state = @agent_config.media_state(t)
|
|
792
|
+
entry = @agent_config.find_model_by_type(t)
|
|
793
|
+
out[t] = {
|
|
794
|
+
source: state["source"],
|
|
795
|
+
model: state["model"],
|
|
796
|
+
base_url: state["base_url"],
|
|
797
|
+
api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
|
|
798
|
+
provider: state["provider"],
|
|
799
|
+
available: state["available"],
|
|
800
|
+
configured: state["configured"]
|
|
801
|
+
}
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Surface what the current default model can offer, even when the
|
|
805
|
+
# user is currently in "off" — the UI uses this to render the
|
|
806
|
+
# auto-mode preview ("Auto would use X").
|
|
807
|
+
default = @agent_config.find_model_by_type("default")
|
|
808
|
+
provider_id = default && Clacky::Providers.resolve_provider(
|
|
809
|
+
base_url: default["base_url"],
|
|
810
|
+
api_key: default["api_key"]
|
|
811
|
+
)
|
|
812
|
+
defaults = {}
|
|
813
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
814
|
+
defaults[t] = {
|
|
815
|
+
provider: provider_id,
|
|
816
|
+
model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
|
|
817
|
+
available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
|
|
818
|
+
}
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
json_response(res, 200, { media: out, default_provider: defaults })
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# PATCH /api/config/media/:kind
|
|
825
|
+
# Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
|
|
826
|
+
# anthropic_format? }
|
|
827
|
+
# off / auto — remove any custom entry; "auto" lets the virtual
|
|
828
|
+
# derivation in AgentConfig#find_model_by_type take over.
|
|
829
|
+
# custom — replace any existing custom entry with the supplied fields.
|
|
830
|
+
def api_update_media_config(kind, req, res)
|
|
831
|
+
body = parse_json_body(req) || {}
|
|
832
|
+
source = body["source"].to_s
|
|
833
|
+
unless %w[off auto custom].include?(source)
|
|
834
|
+
return json_response(res, 422, { error: "invalid source" })
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
@agent_config.models.reject! { |m| m["type"] == kind }
|
|
838
|
+
|
|
839
|
+
if source == "custom"
|
|
840
|
+
model = body["model"].to_s.strip
|
|
841
|
+
base_url = body["base_url"].to_s.strip
|
|
842
|
+
api_key = body["api_key"].to_s
|
|
843
|
+
if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
|
|
844
|
+
return json_response(res, 422, { error: "model, base_url, api_key are required" })
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
@agent_config.models << {
|
|
848
|
+
"id" => SecureRandom.uuid,
|
|
849
|
+
"model" => model,
|
|
850
|
+
"base_url" => base_url,
|
|
851
|
+
"api_key" => api_key,
|
|
852
|
+
"anthropic_format" => body["anthropic_format"] || false,
|
|
853
|
+
"type" => kind
|
|
854
|
+
}
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
@agent_config.save
|
|
858
|
+
json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
|
|
859
|
+
rescue => e
|
|
860
|
+
json_response(res, 422, { error: e.message })
|
|
861
|
+
end
|
|
862
|
+
|
|
700
863
|
# POST /api/onboard/complete
|
|
701
864
|
# Called after key setup is done (soul_setup is optional/skipped).
|
|
702
865
|
# Creates the default session if none exists yet, returns it.
|
|
@@ -1614,7 +1777,7 @@ module Clacky
|
|
|
1614
1777
|
# Returns current config and running status for all supported platforms.
|
|
1615
1778
|
# POST /api/tool/browser
|
|
1616
1779
|
# Executes a browser tool action via the shared BrowserManager daemon.
|
|
1617
|
-
# Used by skill scripts
|
|
1780
|
+
# Used by skill scripts to reuse the server's
|
|
1618
1781
|
# existing Chrome connection without spawning a second MCP daemon.
|
|
1619
1782
|
#
|
|
1620
1783
|
# Request body: JSON with same params as the browser tool
|
|
@@ -3285,8 +3448,13 @@ module Clacky
|
|
|
3285
3448
|
type: m["type"]
|
|
3286
3449
|
}
|
|
3287
3450
|
end
|
|
3288
|
-
# Filter out auto-injected models (
|
|
3289
|
-
|
|
3451
|
+
# Filter out auto-injected models (lite, derived media) AND media
|
|
3452
|
+
# entries (image/video/audio) — those are managed via the dedicated
|
|
3453
|
+
# media-config UI, not the chat-model card list.
|
|
3454
|
+
models.reject! do |m|
|
|
3455
|
+
raw = @agent_config.models[m[:index]]
|
|
3456
|
+
raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
|
|
3457
|
+
end
|
|
3290
3458
|
json_response(res, 200, {
|
|
3291
3459
|
models: models,
|
|
3292
3460
|
current_index: @agent_config.current_model_index,
|
|
@@ -3506,29 +3674,51 @@ module Clacky
|
|
|
3506
3674
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
3507
3675
|
|
|
3508
3676
|
api_key = body["api_key"].to_s
|
|
3509
|
-
# If masked, use the stored key from the matching model (by index or current)
|
|
3510
3677
|
if api_key.include?("****")
|
|
3511
3678
|
idx = body["index"]&.to_i || @agent_config.current_model_index
|
|
3512
3679
|
api_key = @agent_config.models.dig(idx, "api_key").to_s
|
|
3513
3680
|
end
|
|
3514
3681
|
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
json_response(res, 200, { ok: false, message:
|
|
3682
|
+
model = body["model"].to_s
|
|
3683
|
+
base_url = body["base_url"].to_s
|
|
3684
|
+
anthropic_format = body["anthropic_format"] || false
|
|
3685
|
+
|
|
3686
|
+
result, used_base_url = try_test_with_base_url(api_key, base_url, model, anthropic_format)
|
|
3687
|
+
|
|
3688
|
+
if result[:success] && used_base_url != base_url
|
|
3689
|
+
json_response(res, 200, {
|
|
3690
|
+
ok: true,
|
|
3691
|
+
message: "Connected (auto-corrected base_url to add /v1)",
|
|
3692
|
+
effective_base_url: used_base_url
|
|
3693
|
+
})
|
|
3694
|
+
elsif result[:success]
|
|
3695
|
+
json_response(res, 200, { ok: true, message: "Connected successfully" })
|
|
3696
|
+
else
|
|
3697
|
+
json_response(res, 200, { ok: false, message: result[:error].to_s })
|
|
3531
3698
|
end
|
|
3699
|
+
rescue => e
|
|
3700
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
3701
|
+
end
|
|
3702
|
+
|
|
3703
|
+
private def try_test_with_base_url(api_key, base_url, model, anthropic_format)
|
|
3704
|
+
result = run_test_connection(api_key, base_url, model, anthropic_format)
|
|
3705
|
+
return [result, base_url] if result[:success]
|
|
3706
|
+
return [result, base_url] unless result[:status] == 404
|
|
3707
|
+
return [result, base_url] if base_url.match?(%r{/v\d+/?\z})
|
|
3708
|
+
|
|
3709
|
+
candidate = "#{base_url.chomp("/")}/v1"
|
|
3710
|
+
retried = run_test_connection(api_key, candidate, model, anthropic_format)
|
|
3711
|
+
retried[:success] ? [retried, candidate] : [result, base_url]
|
|
3712
|
+
end
|
|
3713
|
+
|
|
3714
|
+
private def run_test_connection(api_key, base_url, model, anthropic_format)
|
|
3715
|
+
client = Clacky::Client.new(
|
|
3716
|
+
api_key,
|
|
3717
|
+
base_url: base_url,
|
|
3718
|
+
model: model,
|
|
3719
|
+
anthropic_format: anthropic_format
|
|
3720
|
+
)
|
|
3721
|
+
client.test_connection(model: model)
|
|
3532
3722
|
end
|
|
3533
3723
|
|
|
3534
3724
|
# GET /api/providers — return built-in provider presets for quick setup
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -23,6 +23,11 @@ module Clacky
|
|
|
23
23
|
# POST /api/v1/telemetry/startup
|
|
24
24
|
# POST /api/v1/telemetry/task
|
|
25
25
|
module Telemetry
|
|
26
|
+
LAUNCH_SOURCES = {
|
|
27
|
+
"installer" => "installer",
|
|
28
|
+
nil => "cli"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
26
31
|
class << self
|
|
27
32
|
# Called on every CLI startup (agent and server mode).
|
|
28
33
|
# No local dedup — the server deduplicates by device_hash for unique
|
|
@@ -32,11 +37,12 @@ module Clacky
|
|
|
32
37
|
|
|
33
38
|
brand = Clacky::BrandConfig.load
|
|
34
39
|
payload = {
|
|
35
|
-
device_id:
|
|
36
|
-
version:
|
|
37
|
-
os:
|
|
38
|
-
ruby_version:
|
|
39
|
-
brand:
|
|
40
|
+
device_id: resolve_device_id(brand),
|
|
41
|
+
version: Clacky::VERSION,
|
|
42
|
+
os: RbConfig::CONFIG["host_os"],
|
|
43
|
+
ruby_version: RUBY_VERSION,
|
|
44
|
+
brand: brand.branded? ? brand.package_name : nil,
|
|
45
|
+
launch_source: LAUNCH_SOURCES.fetch(ENV["CLACKY_LAUNCHED_BY"], "cli")
|
|
40
46
|
}.compact
|
|
41
47
|
|
|
42
48
|
fire_and_forget("/api/v1/telemetry/startup", payload)
|
data/lib/clacky/version.rb
CHANGED