rubik 0.3.3 → 0.3.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/CHANGELOG.md +48 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +16 -20
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
- data/lib/rubik/platform/process.rb +6 -6
- data/lib/rubik/platform/sanitize.rb +37 -16
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_execution.rb +100 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/workspace.rb +156 -152
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
|
@@ -18,6 +18,10 @@ module Rubik
|
|
|
18
18
|
# ToolTeachings/Notices. Byte-identical rendering is pinned by
|
|
19
19
|
# test_prompt_golden_output — reword here = fixture diff there, never
|
|
20
20
|
# a context_builder.rb diff.
|
|
21
|
+
#
|
|
22
|
+
# Security Note: All strings and heredocs in this module contain pure
|
|
23
|
+
# prompt template prose for LLM system context. Backticks (`...`) in prose
|
|
24
|
+
# are markdown inline-code tags and are never evaluated as shell execution.
|
|
21
25
|
module Doctrine
|
|
22
26
|
module_function
|
|
23
27
|
|
|
@@ -80,107 +80,120 @@ module Rubik
|
|
|
80
80
|
next resume_suspended_goal(ctx) if args.length == 1 && sub == "resume"
|
|
81
81
|
next list_suspended_goals(ctx) if args.length == 1 && sub == "list"
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
filtered_args = []
|
|
86
|
-
i = 0
|
|
87
|
-
while i < args.length
|
|
88
|
-
arg = args[i]
|
|
89
|
-
if (m = arg.match(/\A--steps=(\d+)\z/))
|
|
90
|
-
custom_steps = m[1].to_i
|
|
91
|
-
elsif arg.start_with?("--steps=")
|
|
92
|
-
# --steps=X ama X sayı değil: flag metne sızmasın.
|
|
93
|
-
bad_steps = true
|
|
94
|
-
elsif ["--steps", "-s"].include?(arg)
|
|
95
|
-
if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
|
|
96
|
-
custom_steps = args[i + 1].to_i
|
|
97
|
-
i += 1
|
|
98
|
-
else
|
|
99
|
-
# Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
|
|
100
|
-
# yanlış koşu yerine).
|
|
101
|
-
bad_steps = true
|
|
102
|
-
end
|
|
103
|
-
else
|
|
104
|
-
filtered_args << arg
|
|
105
|
-
end
|
|
106
|
-
i += 1
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
if bad_steps || (custom_steps && custom_steps < 1)
|
|
83
|
+
parsed = parse_goal_args(args)
|
|
84
|
+
if !parsed[:valid]
|
|
110
85
|
ctx.out.puts USAGE_GOAL
|
|
111
86
|
ctx.out.puts "⚠️ [Rubik::Goal] --steps pozitif bir tam sayı bekliyor (örn. !goal --steps 200 <hedef>)"
|
|
112
87
|
next
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
goal_text = filtered_args.join(" ").strip
|
|
116
|
-
if goal_text.empty?
|
|
88
|
+
elsif parsed[:goal_text].empty?
|
|
117
89
|
ctx.out.puts USAGE_GOAL
|
|
118
90
|
next
|
|
119
91
|
end
|
|
120
92
|
|
|
121
|
-
ctx
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
125
|
-
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
93
|
+
execute_goal(ctx, parsed[:goal_text], parsed[:custom_steps])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
126
96
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
97
|
+
private_class_method def self.parse_goal_args(args)
|
|
98
|
+
custom_steps = nil
|
|
99
|
+
bad_steps = false
|
|
100
|
+
filtered_args = []
|
|
101
|
+
i = 0
|
|
102
|
+
while i < args.length
|
|
103
|
+
arg = args[i]
|
|
104
|
+
if (m = arg.match(/\A--steps=(\d+)\z/))
|
|
105
|
+
custom_steps = m[1].to_i
|
|
106
|
+
elsif arg.start_with?("--steps=")
|
|
107
|
+
# --steps=X ama X sayı değil: flag metne sızmasın.
|
|
108
|
+
bad_steps = true
|
|
109
|
+
elsif ["--steps", "-s"].include?(arg)
|
|
110
|
+
if i + 1 < args.length && args[i + 1] =~ /\A\d+\z/
|
|
111
|
+
custom_steps = args[i + 1].to_i
|
|
112
|
+
i += 1
|
|
113
|
+
else
|
|
114
|
+
# Değersiz/sayı-olmayan değer: kullanım hatası (sessiz
|
|
115
|
+
# yanlış koşu yerine).
|
|
116
|
+
bad_steps = true
|
|
117
|
+
end
|
|
118
|
+
else
|
|
119
|
+
filtered_args << arg
|
|
150
120
|
end
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
|
|
154
|
-
goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
121
|
+
i += 1
|
|
122
|
+
end
|
|
155
123
|
|
|
156
|
-
|
|
157
|
-
|
|
124
|
+
goal_text = filtered_args.join(" ").strip
|
|
125
|
+
valid = !bad_steps && (custom_steps.nil? || custom_steps >= 1)
|
|
126
|
+
{ custom_steps: custom_steps, goal_text: goal_text, valid: valid }
|
|
127
|
+
end
|
|
158
128
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
model: routed[:model],
|
|
162
|
-
max_steps: goal_steps,
|
|
163
|
-
warn_at_ratio: warn_ratio,
|
|
164
|
-
context_limit: routed[:context_limit],
|
|
165
|
-
max_output_tokens: routed[:max_output_tokens],
|
|
166
|
-
on_step: build_goal_on_step(ctx, goal_state)
|
|
167
|
-
)
|
|
129
|
+
private_class_method def self.initialize_goal_ledger(ctx, goal_text)
|
|
130
|
+
return nil unless Rubik::Orchestrator::GoalLedger::Ledger.enabled?(ctx.config)
|
|
168
131
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
132
|
+
store = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::STORAGE_STORE)
|
|
133
|
+
dmesg = Rubik::Coeffects.get(ctx.runtime_manager&.context, Rubik::Coeffects::DMESG)
|
|
134
|
+
goal_ledger = Rubik::Orchestrator::GoalLedger::Ledger.create!(
|
|
135
|
+
goal_text: goal_text, config: ctx.config, store: store, dmesg: dmesg,
|
|
136
|
+
event_bus: ctx.event_bus
|
|
137
|
+
)
|
|
138
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_LEDGER, goal_ledger)
|
|
139
|
+
goal_ledger
|
|
140
|
+
end
|
|
174
141
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
142
|
+
private_class_method def self.execute_goal(ctx, goal_text, custom_steps)
|
|
143
|
+
ctx.recorder&.record_user_turn("!goal #{goal_text}")
|
|
144
|
+
goal_steps = custom_steps || ctx.config.fetch("agent.goal_max_steps", 100).to_i
|
|
145
|
+
warn_ratio = ctx.config.fetch("agent.warn_at_ratio", 0.75).to_f
|
|
146
|
+
routed = ctx.router.route(task_type: :goal, prompt: goal_text)
|
|
147
|
+
ctx.runtime_manager.provide(:llm_client, routed[:provider]) if routed[:provider]
|
|
178
148
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
149
|
+
# Canlı hedef durumu: maestro ve alt ajanlar read_file
|
|
150
|
+
# rubik://goal/state ile aynı anlık görüntüyü okur. Provide swap
|
|
151
|
+
# semantiğine sahiptir — tekrarlanan !goal koşuları Ω'ya inverse
|
|
152
|
+
# yığmaz; session dispose'u tek delete-inverse ile temizler.
|
|
153
|
+
# event_bus: bitiş olayı (GoalState::FINISH_EVENT) Vakanüvis
|
|
154
|
+
# kronikçisinin goal kancasına akar (K3) — notice/wake yok.
|
|
155
|
+
goal_state = Rubik::Orchestrator::GoalState.new(event_bus: ctx.event_bus)
|
|
156
|
+
goal_state.start!(goal_text, goal_steps)
|
|
157
|
+
ctx.runtime_manager.provide(Rubik::Coeffects::GOAL_STATE, goal_state)
|
|
158
|
+
|
|
159
|
+
# Yapılandırılmış Σ defteri (goal-ledger planı A): knob ON iken
|
|
160
|
+
# koşuyla doğar, terminal dallarda finalize olur, provide swap
|
|
161
|
+
# semantiğiyle tekrarlanan !goal koşuları Ω'ya inverse yığmaz.
|
|
162
|
+
# OFF iken hiçbir yüz kurulmaz (araç/URI/prompt bayt-özdeş).
|
|
163
|
+
goal_ledger = initialize_goal_ledger(ctx, goal_text)
|
|
164
|
+
|
|
165
|
+
# Askı kimliği (issue #106): koşu boyunca SABİT — registry
|
|
166
|
+
# girdisinin anahtarı ve Σ damgası meta'sının goal_id'si.
|
|
167
|
+
# (Σ satır id'si her kabulde değişir; kimlik olarak kullanılamaz.)
|
|
168
|
+
goal_id = "goal-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
169
|
+
|
|
170
|
+
ctx.out.puts "[Rubik::Goal] Starting autonomous goal loop (Budget: #{goal_steps} steps) with #{routed[:role]} model (#{routed[:model]})..."
|
|
171
|
+
ctx.out.puts "[Rubik::Goal] Target: #{goal_text}\n"
|
|
172
|
+
|
|
173
|
+
orchestrator = ctx.orchestrator_loop(
|
|
174
|
+
provider: routed[:provider],
|
|
175
|
+
model: routed[:model],
|
|
176
|
+
max_steps: goal_steps,
|
|
177
|
+
warn_at_ratio: warn_ratio,
|
|
178
|
+
context_limit: routed[:context_limit],
|
|
179
|
+
max_output_tokens: routed[:max_output_tokens],
|
|
180
|
+
on_step: build_goal_on_step(ctx, goal_state)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Plan 53 D-8 yardımcısı: nötr çift + kayıtçı eşlemesi tek yerde.
|
|
184
|
+
# Session restore kayıtçı OLAYLARINDAN hidrate eder — eşlenmiş
|
|
185
|
+
# restore için conversation_history VE record_assistant_response
|
|
186
|
+
# birlikte yazılmalı (başarısızlıkta da).
|
|
187
|
+
record_goal_pair = build_goal_record_pair(ctx, goal_text)
|
|
188
|
+
|
|
189
|
+
# Pruner apply seam: hedef döngüsü de aynı sınırda tohumlanır —
|
|
190
|
+
# aşamalı budama planı varsa goal promptundan ÖNCE uygulanır.
|
|
191
|
+
ctx.apply_staged_prune_plan!
|
|
192
|
+
|
|
193
|
+
drive_goal_turn(ctx, orchestrator: orchestrator, goal_state: goal_state,
|
|
194
|
+
goal_ledger: goal_ledger, goal_text: goal_text, goal_id: goal_id,
|
|
195
|
+
record_pair: record_goal_pair,
|
|
196
|
+
start: ->(loop_inst) { loop_inst.run(goal_text, goal_mode: true, conversation_history: ctx.conversation_history) })
|
|
184
197
|
end
|
|
185
198
|
|
|
186
199
|
# ------------------------------------------------------------------
|
|
@@ -34,173 +34,207 @@ module Rubik
|
|
|
34
34
|
when "list", "ls", "status"
|
|
35
35
|
render_provider_catalog(ctx)
|
|
36
36
|
when "add"
|
|
37
|
-
|
|
38
|
-
arg2 = clean_args[2]
|
|
39
|
-
arg3 = clean_args[3]
|
|
40
|
-
|
|
41
|
-
p_type = "openai_compatible"
|
|
42
|
-
p_endpoint = nil
|
|
43
|
-
|
|
44
|
-
if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
|
|
45
|
-
p_endpoint = arg2
|
|
46
|
-
elsif arg2 && arg3
|
|
47
|
-
p_type = arg2.downcase
|
|
48
|
-
p_endpoint = arg3
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
|
|
52
|
-
ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
|
|
53
|
-
"[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
|
|
54
|
-
elsif !p_endpoint.start_with?("http://") && !p_endpoint.start_with?("https://")
|
|
55
|
-
ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
|
|
56
|
-
"Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
|
|
57
|
-
"[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
|
|
58
|
-
else
|
|
59
|
-
key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
|
|
60
|
-
key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
|
|
61
|
-
model_arg = clean_args.find { |a| a.start_with?("--model=") }
|
|
62
|
-
context_arg = clean_args.find { |a| a.start_with?("--context=") }
|
|
63
|
-
|
|
64
|
-
api_key = key_arg&.split("=", 2)&.last
|
|
65
|
-
key_env = key_env_arg&.split("=", 2)&.last
|
|
66
|
-
m_name = model_arg ? model_arg.split("=", 2).last : "default"
|
|
67
|
-
c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
|
|
68
|
-
|
|
69
|
-
normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
|
|
70
|
-
Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
|
|
71
|
-
else
|
|
72
|
-
p_endpoint
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
p_spec = {
|
|
76
|
-
"type" => p_type,
|
|
77
|
-
"endpoint" => normalized_endpoint,
|
|
78
|
-
"api_key" => api_key,
|
|
79
|
-
"api_key_env" => key_env,
|
|
80
|
-
"models" => [
|
|
81
|
-
{ "name" => m_name, "context_limit" => c_limit }
|
|
82
|
-
]
|
|
83
|
-
}.compact
|
|
84
|
-
|
|
85
|
-
res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
|
|
86
|
-
ctx.refresh_llm_registry!
|
|
87
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
88
|
-
|
|
89
|
-
ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
|
|
90
|
-
"with model '#{m_name}' [#{Base.scope_label(scope)}]."
|
|
91
|
-
end
|
|
37
|
+
handle_add_provider(clean_args, ctx, scope)
|
|
92
38
|
when "add-model", "model-add"
|
|
93
|
-
|
|
94
|
-
m_name = clean_args[2]
|
|
95
|
-
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
|
|
96
|
-
ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
|
|
97
|
-
else
|
|
98
|
-
context_arg = clean_args.find { |a| a.start_with?("--context=") }
|
|
99
|
-
c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
|
|
100
|
-
|
|
101
|
-
res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
|
|
102
|
-
ctx.refresh_llm_registry!
|
|
103
|
-
if res[:ok]
|
|
104
|
-
ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
105
|
-
elsif res[:reason] == :unknown_provider
|
|
106
|
-
ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
|
|
107
|
-
else
|
|
108
|
-
Base.report_persist_failure(ctx, res, scope)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
39
|
+
handle_add_provider_model(clean_args, ctx, scope)
|
|
111
40
|
when "remove-model", "model-remove", "model-rm"
|
|
112
|
-
|
|
113
|
-
m_name = clean_args[2]
|
|
114
|
-
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
|
|
115
|
-
ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
|
|
116
|
-
else
|
|
117
|
-
res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
|
|
118
|
-
ctx.refresh_llm_registry!
|
|
119
|
-
if res[:ok]
|
|
120
|
-
ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
121
|
-
elsif res[:reason] == :unknown_provider
|
|
122
|
-
ctx.out.puts "Provider '#{p_name}' not found."
|
|
123
|
-
else
|
|
124
|
-
Base.report_persist_failure(ctx, res, scope)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
41
|
+
handle_remove_provider_model(clean_args, ctx, scope)
|
|
127
42
|
when "set"
|
|
128
|
-
|
|
129
|
-
field = clean_args[2]&.downcase
|
|
130
|
-
val = clean_args[3]
|
|
131
|
-
if p_name.nil? || p_name.empty? || field.nil? || val.nil?
|
|
132
|
-
ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
|
|
133
|
-
else
|
|
134
|
-
p_spec = ctx.config.provider_spec(p_name)
|
|
135
|
-
if p_spec.nil?
|
|
136
|
-
ctx.out.puts "Provider '#{p_name}' not found."
|
|
137
|
-
else
|
|
138
|
-
case field
|
|
139
|
-
when "key", "api_key"
|
|
140
|
-
p_spec["api_key"] = val
|
|
141
|
-
when "key-env", "key_env", "api_key_env"
|
|
142
|
-
p_spec["api_key_env"] = val
|
|
143
|
-
when "endpoint", "url", "host"
|
|
144
|
-
if !val.start_with?("http://") && !val.start_with?("https://")
|
|
145
|
-
ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
|
|
146
|
-
next
|
|
147
|
-
end
|
|
148
|
-
p_spec["endpoint"] = val
|
|
149
|
-
when "type"
|
|
150
|
-
p_spec["type"] = val
|
|
151
|
-
else
|
|
152
|
-
# Free-form fields (stream, tool_native, num_ctx, ...) are
|
|
153
|
-
# typed through parse_scalar_value so "false" becomes a
|
|
154
|
-
# real false — a raw "false" String is truthy in Ruby and
|
|
155
|
-
# would silently re-enable the flag it means to disable.
|
|
156
|
-
p_spec[field] = ctx.config.parse_scalar_value(val)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
|
|
160
|
-
ctx.refresh_llm_registry!
|
|
161
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
162
|
-
|
|
163
|
-
ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
|
|
164
|
-
end
|
|
165
|
-
end
|
|
43
|
+
handle_set_provider_field(clean_args, ctx, scope)
|
|
166
44
|
when "remove", "rm", "delete"
|
|
167
|
-
|
|
168
|
-
if p_name.nil? || p_name.empty?
|
|
169
|
-
ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
|
|
170
|
-
else
|
|
171
|
-
res = ctx.config.remove_provider(p_name, persist: scope)
|
|
172
|
-
ctx.refresh_llm_registry!
|
|
173
|
-
if res[:ok]
|
|
174
|
-
ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
175
|
-
elsif res[:reason] == :unknown_provider
|
|
176
|
-
ctx.out.puts "Provider '#{p_name}' not found."
|
|
177
|
-
else
|
|
178
|
-
Base.report_persist_failure(ctx, res, scope)
|
|
179
|
-
end
|
|
180
|
-
end
|
|
45
|
+
handle_remove_provider(clean_args, ctx, scope)
|
|
181
46
|
when "set-context", "context", "limit"
|
|
182
|
-
|
|
183
|
-
m_name = clean_args[2]
|
|
184
|
-
limit_val = clean_args[3]&.to_i
|
|
185
|
-
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
|
|
186
|
-
ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
|
|
187
|
-
else
|
|
188
|
-
res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
|
|
189
|
-
ctx.refresh_llm_registry!
|
|
190
|
-
if res[:ok]
|
|
191
|
-
ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
|
|
192
|
-
elsif res[:reason] == :unknown_provider
|
|
193
|
-
ctx.out.puts "Provider '#{p_name}' not found."
|
|
194
|
-
else
|
|
195
|
-
Base.report_persist_failure(ctx, res, scope)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
47
|
+
handle_set_provider_context(clean_args, ctx, scope)
|
|
198
48
|
else
|
|
199
49
|
ctx.out.puts USAGE_PROVIDER
|
|
200
50
|
end
|
|
201
51
|
end
|
|
202
52
|
end
|
|
203
53
|
|
|
54
|
+
private_class_method def self.handle_add_provider(clean_args, ctx, scope)
|
|
55
|
+
p_name = clean_args[1]&.downcase
|
|
56
|
+
arg2 = clean_args[2]
|
|
57
|
+
arg3 = clean_args[3]
|
|
58
|
+
|
|
59
|
+
p_type = "openai_compatible"
|
|
60
|
+
p_endpoint = nil
|
|
61
|
+
|
|
62
|
+
if arg2 && (arg2.start_with?("http://") || arg2.start_with?("https://"))
|
|
63
|
+
p_endpoint = arg2
|
|
64
|
+
elsif arg2 && arg3
|
|
65
|
+
p_type = arg2.downcase
|
|
66
|
+
p_endpoint = arg3
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if p_name.nil? || p_name.empty? || p_endpoint.nil? || p_endpoint.empty?
|
|
70
|
+
ctx.out.puts "Usage: !provider add <name> [type] <endpoint_url> " \
|
|
71
|
+
"[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless p_endpoint.start_with?("http://") || p_endpoint.start_with?("https://")
|
|
76
|
+
ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). " \
|
|
77
|
+
"Received: '#{p_endpoint}'\nUsage: !provider add <name> [type] <endpoint_url> " \
|
|
78
|
+
"[--key=API_KEY | --key-env=ENV_VAR] [--model=NAME] [--context=LIMIT] [--project|--global]"
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
key_arg = clean_args.find { |a| a.start_with?("--key=") || a.start_with?("--api-key=") }
|
|
83
|
+
key_env_arg = clean_args.find { |a| a.start_with?("--key-env=") }
|
|
84
|
+
model_arg = clean_args.find { |a| a.start_with?("--model=") }
|
|
85
|
+
context_arg = clean_args.find { |a| a.start_with?("--context=") }
|
|
86
|
+
|
|
87
|
+
api_key = key_arg&.split("=", 2)&.last
|
|
88
|
+
key_env = key_env_arg&.split("=", 2)&.last
|
|
89
|
+
m_name = model_arg ? model_arg.split("=", 2).last : "default"
|
|
90
|
+
c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
|
|
91
|
+
|
|
92
|
+
normalized_endpoint = if ["openai_compatible", "openai"].include?(p_type)
|
|
93
|
+
Rubik::LLM::OpenAICompatibleProvider.normalize_endpoint(p_endpoint)
|
|
94
|
+
else
|
|
95
|
+
p_endpoint
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
p_spec = {
|
|
99
|
+
"type" => p_type,
|
|
100
|
+
"endpoint" => normalized_endpoint,
|
|
101
|
+
"api_key" => api_key,
|
|
102
|
+
"api_key_env" => key_env,
|
|
103
|
+
"models" => [
|
|
104
|
+
{ "name" => m_name, "context_limit" => c_limit }
|
|
105
|
+
]
|
|
106
|
+
}.compact
|
|
107
|
+
|
|
108
|
+
res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
|
|
109
|
+
ctx.refresh_llm_registry!
|
|
110
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
111
|
+
|
|
112
|
+
ctx.out.puts "Successfully added provider '#{p_name}' (#{p_type} -> #{normalized_endpoint}) " \
|
|
113
|
+
"with model '#{m_name}' [#{Base.scope_label(scope)}]."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method def self.handle_add_provider_model(clean_args, ctx, scope)
|
|
117
|
+
p_name = clean_args[1]&.downcase
|
|
118
|
+
m_name = clean_args[2]
|
|
119
|
+
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
|
|
120
|
+
ctx.out.puts "Usage: !provider add-model <provider_name> <model_name> [--context=LIMIT] [--project|--global]"
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
context_arg = clean_args.find { |a| a.start_with?("--context=") }
|
|
125
|
+
c_limit = context_arg ? context_arg.split("=", 2).last.to_i : 32_768
|
|
126
|
+
|
|
127
|
+
res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: c_limit, persist: scope)
|
|
128
|
+
ctx.refresh_llm_registry!
|
|
129
|
+
if res[:ok]
|
|
130
|
+
ctx.out.puts "Added model '#{m_name}' (#{c_limit} tokens) to provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
131
|
+
elsif res[:reason] == :unknown_provider
|
|
132
|
+
ctx.out.puts "Provider '#{p_name}' not found. Add it first via !provider add."
|
|
133
|
+
else
|
|
134
|
+
Base.report_persist_failure(ctx, res, scope)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private_class_method def self.handle_remove_provider_model(clean_args, ctx, scope)
|
|
139
|
+
p_name = clean_args[1]&.downcase
|
|
140
|
+
m_name = clean_args[2]
|
|
141
|
+
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty?
|
|
142
|
+
ctx.out.puts "Usage: !provider remove-model <provider_name> <model_name> [--project|--global]"
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
res = ctx.config.remove_provider_model(p_name, m_name, persist: scope)
|
|
147
|
+
ctx.refresh_llm_registry!
|
|
148
|
+
if res[:ok]
|
|
149
|
+
ctx.out.puts "Removed model '#{m_name}' from provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
150
|
+
elsif res[:reason] == :unknown_provider
|
|
151
|
+
ctx.out.puts "Provider '#{p_name}' not found."
|
|
152
|
+
else
|
|
153
|
+
Base.report_persist_failure(ctx, res, scope)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private_class_method def self.handle_set_provider_field(clean_args, ctx, scope)
|
|
158
|
+
p_name = clean_args[1]&.downcase
|
|
159
|
+
field = clean_args[2]&.downcase
|
|
160
|
+
val = clean_args[3]
|
|
161
|
+
if p_name.nil? || p_name.empty? || field.nil? || val.nil?
|
|
162
|
+
ctx.out.puts "Usage: !provider set <name> <endpoint|key|key-env|type|stream|stream_timeout|open_timeout|tool_native|num_ctx> <value> [--project|--global]"
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
p_spec = ctx.config.provider_spec(p_name)
|
|
167
|
+
if p_spec.nil?
|
|
168
|
+
ctx.out.puts "Provider '#{p_name}' not found."
|
|
169
|
+
return
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
case field
|
|
173
|
+
when "key", "api_key"
|
|
174
|
+
p_spec["api_key"] = val
|
|
175
|
+
when "key-env", "key_env", "api_key_env"
|
|
176
|
+
p_spec["api_key_env"] = val
|
|
177
|
+
when "endpoint", "url", "host"
|
|
178
|
+
unless val.start_with?("http://") || val.start_with?("https://")
|
|
179
|
+
ctx.out.puts "Error: Endpoint must be a valid HTTP/HTTPS URL (starting with http:// or https://). Received: '#{val}'"
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
p_spec["endpoint"] = val
|
|
183
|
+
when "type"
|
|
184
|
+
p_spec["type"] = val
|
|
185
|
+
else
|
|
186
|
+
# Free-form fields (stream, tool_native, num_ctx, ...) are
|
|
187
|
+
# typed through parse_scalar_value so "false" becomes a
|
|
188
|
+
# real false — a raw "false" String is truthy in Ruby and
|
|
189
|
+
# would silently re-enable the flag it means to disable.
|
|
190
|
+
p_spec[field] = ctx.config.parse_scalar_value(val)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
res = ctx.config.set("providers.#{p_name}", p_spec, persist: scope)
|
|
194
|
+
ctx.refresh_llm_registry!
|
|
195
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
196
|
+
|
|
197
|
+
ctx.out.puts "Updated provider '#{p_name}' field '#{field}' to '#{val}' [#{Base.scope_label(scope)}]."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_class_method def self.handle_remove_provider(clean_args, ctx, scope)
|
|
201
|
+
p_name = clean_args[1]&.downcase
|
|
202
|
+
if p_name.nil? || p_name.empty?
|
|
203
|
+
ctx.out.puts "Usage: !provider remove <name> [--project|--global]"
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
res = ctx.config.remove_provider(p_name, persist: scope)
|
|
208
|
+
ctx.refresh_llm_registry!
|
|
209
|
+
if res[:ok]
|
|
210
|
+
ctx.out.puts "Removed provider '#{p_name}' [#{Base.scope_label(scope)}]."
|
|
211
|
+
elsif res[:reason] == :unknown_provider
|
|
212
|
+
ctx.out.puts "Provider '#{p_name}' not found."
|
|
213
|
+
else
|
|
214
|
+
Base.report_persist_failure(ctx, res, scope)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private_class_method def self.handle_set_provider_context(clean_args, ctx, scope)
|
|
219
|
+
p_name = clean_args[1]&.downcase
|
|
220
|
+
m_name = clean_args[2]
|
|
221
|
+
limit_val = clean_args[3]&.to_i
|
|
222
|
+
if p_name.nil? || p_name.empty? || m_name.nil? || m_name.empty? || limit_val.nil? || limit_val <= 0
|
|
223
|
+
ctx.out.puts "Usage: !provider set-context <provider> <model> <limit_in_tokens> [--project|--global]"
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
res = ctx.config.upsert_provider_model(p_name, m_name, context_limit: limit_val, persist: scope)
|
|
228
|
+
ctx.refresh_llm_registry!
|
|
229
|
+
if res[:ok]
|
|
230
|
+
ctx.out.puts "Updated context limit for model '#{m_name}' on provider '#{p_name}' to #{limit_val} tokens [#{Base.scope_label(scope)}]."
|
|
231
|
+
elsif res[:reason] == :unknown_provider
|
|
232
|
+
ctx.out.puts "Provider '#{p_name}' not found."
|
|
233
|
+
else
|
|
234
|
+
Base.report_persist_failure(ctx, res, scope)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
204
238
|
private_class_method def self.register_providers_command(shell)
|
|
205
239
|
shell.register_command("providers", description: "List all configured LLM providers, endpoints, and models", audience: :both) do |_args, ctx|
|
|
206
240
|
render_provider_catalog(ctx)
|