completion-kit 0.4.1 → 0.4.7
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/app/assets/stylesheets/completion_kit/application.css +1882 -785
- data/app/controllers/completion_kit/runs_controller.rb +34 -19
- data/app/controllers/completion_kit/suggestions_controller.rb +24 -0
- data/app/jobs/completion_kit/generate_row_job.rb +7 -0
- data/app/jobs/completion_kit/judge_review_job.rb +2 -0
- data/app/jobs/completion_kit/model_discovery_job.rb +9 -4
- data/app/models/completion_kit/dataset.rb +9 -0
- data/app/models/completion_kit/provider_credential.rb +12 -1
- data/app/models/completion_kit/response.rb +7 -0
- data/app/models/completion_kit/run.rb +47 -9
- data/app/services/completion_kit/anthropic_client.rb +33 -14
- data/app/services/completion_kit/model_discovery_service.rb +133 -30
- data/app/services/completion_kit/ollama_client.rb +31 -10
- data/app/services/completion_kit/open_ai_client.rb +35 -13
- data/app/services/completion_kit/open_router_client.rb +34 -13
- data/app/services/completion_kit/worker_health.rb +4 -1
- data/app/views/completion_kit/datasets/index.html.erb +1 -1
- data/app/views/completion_kit/datasets/show.html.erb +47 -9
- data/app/views/completion_kit/metrics/_form.html.erb +1 -1
- data/app/views/completion_kit/metrics/index.html.erb +15 -2
- data/app/views/completion_kit/metrics/show.html.erb +1 -1
- data/app/views/completion_kit/prompts/index.html.erb +27 -8
- data/app/views/completion_kit/prompts/show.html.erb +6 -36
- data/app/views/completion_kit/provider_credentials/_discovery_status.html.erb +6 -4
- data/app/views/completion_kit/provider_credentials/_form.html.erb +1 -32
- data/app/views/completion_kit/provider_credentials/_models_card.html.erb +70 -0
- data/app/views/completion_kit/provider_credentials/index.html.erb +1 -1
- data/app/views/completion_kit/responses/show.html.erb +27 -6
- data/app/views/completion_kit/runs/_actions.html.erb +3 -0
- data/app/views/completion_kit/runs/_form.html.erb +114 -20
- data/app/views/completion_kit/runs/_response_row.html.erb +52 -22
- data/app/views/completion_kit/runs/_row.html.erb +50 -0
- data/app/views/completion_kit/runs/_sort_toolbar.html.erb +5 -4
- data/app/views/completion_kit/runs/_status_header.html.erb +7 -31
- data/app/views/completion_kit/runs/_status_panel.html.erb +80 -0
- data/app/views/completion_kit/runs/index.html.erb +4 -16
- data/app/views/completion_kit/runs/show.html.erb +111 -17
- data/app/views/completion_kit/suggestions/show.html.erb +65 -0
- data/app/views/layouts/completion_kit/application.html.erb +71 -0
- data/config/routes.rb +8 -2
- data/db/migrate/20260507000001_add_discovery_error_to_provider_credentials.rb +5 -0
- data/db/migrate/20260507150000_add_temperature_ignored_to_runs.rb +5 -0
- data/lib/completion_kit/version.rb +1 -1
- metadata +9 -4
- data/app/views/completion_kit/runs/_progress.html.erb +0 -18
- data/app/views/completion_kit/runs/suggestion.html.erb +0 -47
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module CompletionKit
|
|
6
6
|
class ModelDiscoveryService
|
|
7
|
+
class DiscoveryError < StandardError; end
|
|
8
|
+
|
|
7
9
|
def initialize(config:)
|
|
8
10
|
@provider = config[:provider]
|
|
9
11
|
@api_key = config[:api_key]
|
|
@@ -13,7 +15,6 @@ module CompletionKit
|
|
|
13
15
|
def refresh!(&on_progress)
|
|
14
16
|
models_with_names = fetch_models
|
|
15
17
|
reconcile(models_with_names)
|
|
16
|
-
return if %w[openrouter ollama].include?(@provider)
|
|
17
18
|
probe_new_models(&on_progress)
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -37,11 +38,31 @@ module CompletionKit
|
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
def raise_fetch_error!(response)
|
|
42
|
+
label = case response.status
|
|
43
|
+
when 401, 403 then "Invalid API key for #{@provider}"
|
|
44
|
+
when 429 then "Rate limited by #{@provider}"
|
|
45
|
+
when 500..599 then "#{@provider} returned #{response.status}"
|
|
46
|
+
else "#{@provider} model list request failed (#{response.status})"
|
|
47
|
+
end
|
|
48
|
+
detail = extract_provider_error_message(response.body)
|
|
49
|
+
raise DiscoveryError, detail.present? ? "#{label}: #{detail}" : label
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def extract_provider_error_message(body)
|
|
53
|
+
return nil if body.blank?
|
|
54
|
+
data = JSON.parse(body)
|
|
55
|
+
err = data["error"]
|
|
56
|
+
(err.is_a?(Hash) && err["message"]) || data["message"] || (err.is_a?(String) && err) || nil
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
body.to_s.truncate(200)
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
def fetch_openai_models
|
|
41
62
|
response = fetch_connection("https://api.openai.com").get("/v1/models") do |req|
|
|
42
63
|
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
43
64
|
end
|
|
44
|
-
|
|
65
|
+
raise_fetch_error!(response) unless response.success?
|
|
45
66
|
JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: nil } }
|
|
46
67
|
end
|
|
47
68
|
|
|
@@ -50,7 +71,7 @@ module CompletionKit
|
|
|
50
71
|
req.headers["x-api-key"] = @api_key
|
|
51
72
|
req.headers["anthropic-version"] = "2023-06-01"
|
|
52
73
|
end
|
|
53
|
-
|
|
74
|
+
raise_fetch_error!(response) unless response.success?
|
|
54
75
|
JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["display_name"] } }
|
|
55
76
|
end
|
|
56
77
|
|
|
@@ -60,7 +81,7 @@ module CompletionKit
|
|
|
60
81
|
req.headers["HTTP-Referer"] = "https://completionkit.com"
|
|
61
82
|
req.headers["X-Title"] = "CompletionKit"
|
|
62
83
|
end
|
|
63
|
-
|
|
84
|
+
raise_fetch_error!(response) unless response.success?
|
|
64
85
|
JSON.parse(response.body).fetch("data", []).filter_map do |entry|
|
|
65
86
|
next nil if entry["deprecated"] == true
|
|
66
87
|
context_length = entry["context_length"].to_i
|
|
@@ -70,12 +91,12 @@ module CompletionKit
|
|
|
70
91
|
end
|
|
71
92
|
|
|
72
93
|
def fetch_ollama_models
|
|
73
|
-
|
|
94
|
+
raise DiscoveryError, "Ollama endpoint URL is required" if @api_endpoint.blank?
|
|
74
95
|
base_url = @api_endpoint.to_s.delete_suffix("/")
|
|
75
96
|
response = fetch_connection(base_url).get("/models") do |req|
|
|
76
97
|
req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
|
|
77
98
|
end
|
|
78
|
-
|
|
99
|
+
raise_fetch_error!(response) unless response.success?
|
|
79
100
|
JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
|
|
80
101
|
end
|
|
81
102
|
|
|
@@ -111,29 +132,55 @@ module CompletionKit
|
|
|
111
132
|
end
|
|
112
133
|
|
|
113
134
|
def probe_new_models(&on_progress)
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
candidates = Model.where(provider: @provider, status: %w[active failed])
|
|
136
|
+
.where("supports_generation IS NULL OR supports_judging IS NULL OR (generation_error IS NOT NULL AND #{retryable_error_sql('generation_error')}) OR (judging_error IS NOT NULL AND #{retryable_error_sql('judging_error')})")
|
|
137
|
+
total = candidates.count
|
|
116
138
|
current = 0
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
candidates.find_each do |model|
|
|
140
|
+
probed = false
|
|
141
|
+
if model.supports_generation.nil? || retryable_error?(model.generation_error)
|
|
142
|
+
model.generation_error = nil
|
|
143
|
+
probe_generation(model)
|
|
144
|
+
probed = true
|
|
145
|
+
end
|
|
146
|
+
if model.supports_generation && (model.supports_judging.nil? || retryable_error?(model.judging_error))
|
|
147
|
+
model.judging_error = nil
|
|
148
|
+
probe_judging(model)
|
|
149
|
+
probed = true
|
|
150
|
+
end
|
|
151
|
+
if probed
|
|
152
|
+
model.probed_at = Time.current
|
|
153
|
+
model.status = (model.supports_generation == false ? "failed" : "active")
|
|
154
|
+
model.save!
|
|
155
|
+
end
|
|
123
156
|
current += 1
|
|
124
157
|
on_progress&.call(current, total)
|
|
125
158
|
end
|
|
126
159
|
end
|
|
127
160
|
|
|
161
|
+
def retryable_error?(error_string)
|
|
162
|
+
return false if error_string.blank?
|
|
163
|
+
return true if error_string.start_with?("429 ")
|
|
164
|
+
!error_string.match?(/\A4\d\d\s/)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def retryable_error_sql(column)
|
|
168
|
+
"(#{column} LIKE '429 -%' OR #{column} NOT LIKE '4__ -%')"
|
|
169
|
+
end
|
|
170
|
+
|
|
128
171
|
def probe_generation(model)
|
|
129
|
-
|
|
172
|
+
probe_input = "Reply with exactly this token and nothing else: PING-OK"
|
|
173
|
+
response = send_probe(model.model_id, probe_input, 65536)
|
|
130
174
|
if response.success?
|
|
131
|
-
text = extract_text(response)
|
|
132
|
-
if text.
|
|
175
|
+
text = extract_text(response).to_s
|
|
176
|
+
if text.blank?
|
|
177
|
+
model.supports_generation = false
|
|
178
|
+
model.generation_error = "Empty response"
|
|
179
|
+
elsif text.include?("PING-OK")
|
|
133
180
|
model.supports_generation = true
|
|
134
181
|
else
|
|
135
182
|
model.supports_generation = false
|
|
136
|
-
model.generation_error = "
|
|
183
|
+
model.generation_error = "Did not follow text completion instruction (likely non-text-output model): #{text.truncate(200)}"
|
|
137
184
|
end
|
|
138
185
|
else
|
|
139
186
|
model.supports_generation = false
|
|
@@ -154,7 +201,7 @@ module CompletionKit
|
|
|
154
201
|
AI output to evaluate: The sky is blue.
|
|
155
202
|
PROMPT
|
|
156
203
|
|
|
157
|
-
response = send_probe(model.model_id, judge_input,
|
|
204
|
+
response = send_probe(model.model_id, judge_input, 65536)
|
|
158
205
|
if response.success?
|
|
159
206
|
text = extract_text(response).to_s
|
|
160
207
|
if text.match?(/Score:\s*\d/i)
|
|
@@ -173,37 +220,60 @@ module CompletionKit
|
|
|
173
220
|
end
|
|
174
221
|
|
|
175
222
|
def send_probe(model_id, input, max_tokens)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
223
|
+
case @provider
|
|
224
|
+
when "openai" then openai_probe(model_id, input, max_tokens)
|
|
225
|
+
when "anthropic" then anthropic_probe(model_id, input, max_tokens)
|
|
226
|
+
when "openrouter" then openrouter_probe(model_id, input, max_tokens)
|
|
227
|
+
when "ollama" then ollama_probe(model_id, input, max_tokens)
|
|
228
|
+
else raise ArgumentError, "Unsupported probe provider: #{@provider}"
|
|
180
229
|
end
|
|
181
230
|
end
|
|
182
231
|
|
|
183
232
|
def extract_text(response)
|
|
184
233
|
data = JSON.parse(response.body)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
234
|
+
case @provider
|
|
235
|
+
when "openai"
|
|
236
|
+
message = Array(data["output"]).find { |o| o["type"] == "message" }
|
|
237
|
+
message&.dig("content", 0, "text")
|
|
238
|
+
when "anthropic" then data.dig("content", 0, "text")
|
|
239
|
+
else data.dig("choices", 0, "message", "content")
|
|
189
240
|
end
|
|
190
241
|
end
|
|
191
242
|
|
|
192
243
|
def openai_probe(model_id, input, max_tokens)
|
|
193
|
-
conn =
|
|
194
|
-
|
|
244
|
+
conn = openai_probe_connection
|
|
245
|
+
response = openai_probe_post(conn, model_id, input, max_tokens, openai_reasoning_effort_for(model_id))
|
|
246
|
+
if response.status == 400 && response.body.to_s.include?("is not supported with the")
|
|
247
|
+
response = openai_probe_post(conn, model_id, input, max_tokens, nil)
|
|
248
|
+
end
|
|
249
|
+
response
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def openai_probe_connection
|
|
253
|
+
Faraday.new(url: "https://api.openai.com") do |f|
|
|
254
|
+
f.options.timeout = 180
|
|
195
255
|
f.options.open_timeout = 5
|
|
196
256
|
f.request :retry, max: 1, interval: 0.5
|
|
197
257
|
f.adapter Faraday.default_adapter
|
|
198
258
|
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def openai_probe_post(conn, model_id, input, max_tokens, effort)
|
|
262
|
+
body = { model: model_id, input: input, max_output_tokens: max_tokens, store: false }
|
|
263
|
+
body[:reasoning] = { effort: effort } if effort
|
|
199
264
|
conn.post do |req|
|
|
200
265
|
req.url "/v1/responses"
|
|
201
266
|
req.headers["Content-Type"] = "application/json"
|
|
202
267
|
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
203
|
-
req.body =
|
|
268
|
+
req.body = body.to_json
|
|
204
269
|
end
|
|
205
270
|
end
|
|
206
271
|
|
|
272
|
+
def openai_reasoning_effort_for(model_id)
|
|
273
|
+
return nil unless model_id.to_s.match?(/\A(gpt-5|o1|o3)/)
|
|
274
|
+
"low"
|
|
275
|
+
end
|
|
276
|
+
|
|
207
277
|
def anthropic_probe(model_id, input, max_tokens)
|
|
208
278
|
conn = Faraday.new(url: "https://api.anthropic.com") do |f|
|
|
209
279
|
f.options.timeout = 15
|
|
@@ -219,5 +289,38 @@ module CompletionKit
|
|
|
219
289
|
req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
|
|
220
290
|
end
|
|
221
291
|
end
|
|
292
|
+
|
|
293
|
+
def openrouter_probe(model_id, input, max_tokens)
|
|
294
|
+
conn = Faraday.new(url: "https://openrouter.ai") do |f|
|
|
295
|
+
f.options.timeout = 30
|
|
296
|
+
f.options.open_timeout = 5
|
|
297
|
+
f.request :retry, max: 1, interval: 0.5
|
|
298
|
+
f.adapter Faraday.default_adapter
|
|
299
|
+
end
|
|
300
|
+
conn.post do |req|
|
|
301
|
+
req.url "/api/v1/chat/completions"
|
|
302
|
+
req.headers["Content-Type"] = "application/json"
|
|
303
|
+
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
304
|
+
req.headers["HTTP-Referer"] = "https://completionkit.com"
|
|
305
|
+
req.headers["X-Title"] = "CompletionKit"
|
|
306
|
+
req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def ollama_probe(model_id, input, max_tokens)
|
|
311
|
+
base_url = @api_endpoint.to_s.delete_suffix("/")
|
|
312
|
+
conn = Faraday.new(url: base_url) do |f|
|
|
313
|
+
f.options.timeout = 60
|
|
314
|
+
f.options.open_timeout = 5
|
|
315
|
+
f.request :retry, max: 1, interval: 0.5
|
|
316
|
+
f.adapter Faraday.default_adapter
|
|
317
|
+
end
|
|
318
|
+
conn.post do |req|
|
|
319
|
+
req.url "/chat/completions"
|
|
320
|
+
req.headers["Content-Type"] = "application/json"
|
|
321
|
+
req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
|
|
322
|
+
req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
|
|
323
|
+
end
|
|
324
|
+
end
|
|
222
325
|
end
|
|
223
326
|
end
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
class OllamaClient < LlmClient
|
|
3
|
+
def temperature_dropped?
|
|
4
|
+
@temperature_dropped == true
|
|
5
|
+
end
|
|
6
|
+
|
|
3
7
|
def generate_completion(prompt, options = {})
|
|
8
|
+
@temperature_dropped = false
|
|
4
9
|
return "Error: API endpoint not configured" unless configured?
|
|
5
10
|
|
|
6
11
|
model = options[:model]
|
|
7
12
|
max_tokens = options[:max_tokens] || 1000
|
|
8
13
|
temperature = options[:temperature] || 0.7
|
|
9
14
|
|
|
10
|
-
response =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
model: model,
|
|
16
|
-
prompt: prompt,
|
|
17
|
-
max_tokens: max_tokens,
|
|
18
|
-
temperature: temperature
|
|
19
|
-
}.to_json
|
|
15
|
+
response = post_completion(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
|
|
16
|
+
|
|
17
|
+
if response.status == 400 && temperature_unsupported?(response.body)
|
|
18
|
+
@temperature_dropped = true
|
|
19
|
+
response = post_completion(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
if response.status == 429
|
|
@@ -76,5 +76,26 @@ module CompletionKit
|
|
|
76
76
|
def api_endpoint
|
|
77
77
|
(@config[:api_endpoint] || ENV["OLLAMA_API_ENDPOINT"] || "http://localhost:11434/v1").to_s.delete_suffix("/")
|
|
78
78
|
end
|
|
79
|
+
|
|
80
|
+
def post_completion(model:, prompt:, max_tokens:, temperature:)
|
|
81
|
+
body = {
|
|
82
|
+
model: model,
|
|
83
|
+
prompt: prompt,
|
|
84
|
+
max_tokens: max_tokens
|
|
85
|
+
}
|
|
86
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
87
|
+
|
|
88
|
+
build_connection(api_endpoint).post do |req|
|
|
89
|
+
req.url "/v1/completions"
|
|
90
|
+
req.headers["Content-Type"] = "application/json"
|
|
91
|
+
req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
|
|
92
|
+
req.body = body.to_json
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def temperature_unsupported?(body)
|
|
97
|
+
s = body.to_s
|
|
98
|
+
s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
|
|
99
|
+
end
|
|
79
100
|
end
|
|
80
101
|
end
|
|
@@ -6,25 +6,23 @@ module CompletionKit
|
|
|
6
6
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" }
|
|
7
7
|
].freeze
|
|
8
8
|
|
|
9
|
+
def temperature_dropped?
|
|
10
|
+
@temperature_dropped == true
|
|
11
|
+
end
|
|
12
|
+
|
|
9
13
|
def generate_completion(prompt, options = {})
|
|
14
|
+
@temperature_dropped = false
|
|
10
15
|
return "Error: API key not configured" unless configured?
|
|
11
16
|
|
|
12
17
|
model = options[:model] || "gpt-4.1-mini"
|
|
13
18
|
max_tokens = options[:max_tokens] || 1000
|
|
14
19
|
temperature = options[:temperature] || 0.7
|
|
15
20
|
|
|
16
|
-
response =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
model: model,
|
|
22
|
-
input: prompt,
|
|
23
|
-
instructions: "You are a helpful assistant.",
|
|
24
|
-
max_output_tokens: max_tokens,
|
|
25
|
-
temperature: temperature,
|
|
26
|
-
store: false
|
|
27
|
-
}.to_json
|
|
21
|
+
response = post_responses(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
|
|
22
|
+
|
|
23
|
+
if response.status == 400 && temperature_unsupported?(response.body)
|
|
24
|
+
@temperature_dropped = true
|
|
25
|
+
response = post_responses(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
if response.status == 429
|
|
@@ -38,7 +36,8 @@ module CompletionKit
|
|
|
38
36
|
|
|
39
37
|
if response.success?
|
|
40
38
|
data = JSON.parse(response.body)
|
|
41
|
-
data["output"][
|
|
39
|
+
message = Array(data["output"]).find { |o| o["type"] == "message" }
|
|
40
|
+
message&.dig("content", 0, "text").to_s.strip
|
|
42
41
|
else
|
|
43
42
|
"Error: #{response.status} - #{response.body}"
|
|
44
43
|
end
|
|
@@ -69,5 +68,28 @@ module CompletionKit
|
|
|
69
68
|
def api_key
|
|
70
69
|
@config[:api_key] || ENV["OPENAI_API_KEY"]
|
|
71
70
|
end
|
|
71
|
+
|
|
72
|
+
def post_responses(model:, prompt:, max_tokens:, temperature:)
|
|
73
|
+
body = {
|
|
74
|
+
model: model,
|
|
75
|
+
input: prompt,
|
|
76
|
+
instructions: "You are a helpful assistant.",
|
|
77
|
+
max_output_tokens: max_tokens,
|
|
78
|
+
store: false
|
|
79
|
+
}
|
|
80
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
81
|
+
|
|
82
|
+
build_connection("https://api.openai.com").post do |req|
|
|
83
|
+
req.url "/v1/responses"
|
|
84
|
+
req.headers["Content-Type"] = "application/json"
|
|
85
|
+
req.headers["Authorization"] = "Bearer #{api_key}"
|
|
86
|
+
req.body = body.to_json
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def temperature_unsupported?(body)
|
|
91
|
+
s = body.to_s
|
|
92
|
+
s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
|
|
93
|
+
end
|
|
72
94
|
end
|
|
73
95
|
end
|
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
class OpenRouterClient < LlmClient
|
|
3
|
-
BASE_URL = "https://openrouter.ai
|
|
3
|
+
BASE_URL = "https://openrouter.ai".freeze
|
|
4
4
|
REFERER = "https://completionkit.com".freeze
|
|
5
5
|
APP_TITLE = "CompletionKit".freeze
|
|
6
6
|
|
|
7
|
+
def temperature_dropped?
|
|
8
|
+
@temperature_dropped == true
|
|
9
|
+
end
|
|
10
|
+
|
|
7
11
|
def generate_completion(prompt, options = {})
|
|
12
|
+
@temperature_dropped = false
|
|
8
13
|
return "Error: API key not configured" unless configured?
|
|
9
14
|
|
|
10
15
|
model = options[:model] || "openai/gpt-4o-mini"
|
|
11
16
|
max_tokens = options[:max_tokens] || 1000
|
|
12
17
|
temperature = options[:temperature] || 0.7
|
|
13
18
|
|
|
14
|
-
response =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
req.headers["X-Title"] = APP_TITLE
|
|
20
|
-
req.body = {
|
|
21
|
-
model: model,
|
|
22
|
-
messages: [{ role: "user", content: prompt }],
|
|
23
|
-
max_tokens: max_tokens,
|
|
24
|
-
temperature: temperature
|
|
25
|
-
}.to_json
|
|
19
|
+
response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
|
|
20
|
+
|
|
21
|
+
if response.status == 400 && temperature_unsupported?(response.body)
|
|
22
|
+
@temperature_dropped = true
|
|
23
|
+
response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
if response.status == 429
|
|
@@ -67,5 +65,28 @@ module CompletionKit
|
|
|
67
65
|
def api_key
|
|
68
66
|
@config[:api_key] || ENV["OPENROUTER_API_KEY"]
|
|
69
67
|
end
|
|
68
|
+
|
|
69
|
+
def post_chat(model:, prompt:, max_tokens:, temperature:)
|
|
70
|
+
body = {
|
|
71
|
+
model: model,
|
|
72
|
+
messages: [{ role: "user", content: prompt }],
|
|
73
|
+
max_tokens: max_tokens
|
|
74
|
+
}
|
|
75
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
76
|
+
|
|
77
|
+
build_connection(BASE_URL, timeout: 30, open_timeout: 5).post do |req|
|
|
78
|
+
req.url "/api/v1/chat/completions"
|
|
79
|
+
req.headers["Content-Type"] = "application/json"
|
|
80
|
+
req.headers["Authorization"] = "Bearer #{api_key}"
|
|
81
|
+
req.headers["HTTP-Referer"] = REFERER
|
|
82
|
+
req.headers["X-Title"] = APP_TITLE
|
|
83
|
+
req.body = body.to_json
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def temperature_unsupported?(body)
|
|
88
|
+
s = body.to_s
|
|
89
|
+
s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
|
|
90
|
+
end
|
|
70
91
|
end
|
|
71
92
|
end
|
|
@@ -4,7 +4,10 @@ module CompletionKit
|
|
|
4
4
|
|
|
5
5
|
def self.healthy?
|
|
6
6
|
return true unless defined?(::SolidQueue::Process)
|
|
7
|
-
::SolidQueue::Process
|
|
7
|
+
::SolidQueue::Process
|
|
8
|
+
.where("kind LIKE 'Worker%'")
|
|
9
|
+
.where("last_heartbeat_at > ?", HEARTBEAT_THRESHOLD.ago)
|
|
10
|
+
.exists?
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<td><strong><%= dataset.name %></strong></td>
|
|
26
26
|
<td><%= dataset.row_count %></td>
|
|
27
27
|
<td><%= dataset.runs.count %></td>
|
|
28
|
-
<td><%= dataset.created_at.strftime("%Y
|
|
28
|
+
<td class="ck-meta-copy"><time datetime="<%= dataset.created_at.iso8601 %>"><%= dataset.created_at.strftime("%b %-d, %Y") %></time></td>
|
|
29
29
|
<td class="ck-results-table__arrow">→</td>
|
|
30
30
|
</tr>
|
|
31
31
|
<% end %>
|
|
@@ -14,30 +14,68 @@
|
|
|
14
14
|
|
|
15
15
|
<section>
|
|
16
16
|
<p class="ck-kicker">CSV preview</p>
|
|
17
|
-
|
|
17
|
+
<%
|
|
18
|
+
require "csv"
|
|
19
|
+
parsed_rows = []
|
|
20
|
+
parse_error = nil
|
|
21
|
+
begin
|
|
22
|
+
csv = ::CSV.parse(@dataset.csv_data.to_s)
|
|
23
|
+
parsed_rows = csv
|
|
24
|
+
rescue ::CSV::MalformedCSVError => e
|
|
25
|
+
parse_error = e.message
|
|
26
|
+
end
|
|
27
|
+
headers = parsed_rows.first || []
|
|
28
|
+
body_rows = parsed_rows.drop(1)
|
|
29
|
+
%>
|
|
30
|
+
<% if parse_error %>
|
|
31
|
+
<p class="ck-field-hint" style="color: var(--ck-warning);">Could not parse CSV: <%= parse_error %></p>
|
|
32
|
+
<pre class="ck-code ck-code--dark"><%= @dataset.csv_data %></pre>
|
|
33
|
+
<% elsif headers.empty? %>
|
|
34
|
+
<p class="ck-field-hint">Dataset is empty.</p>
|
|
35
|
+
<% else %>
|
|
36
|
+
<div class="ck-csv-table-wrap">
|
|
37
|
+
<table class="ck-csv-table">
|
|
38
|
+
<thead>
|
|
39
|
+
<tr>
|
|
40
|
+
<th class="ck-csv-table__rownum">#</th>
|
|
41
|
+
<% headers.each do |h| %>
|
|
42
|
+
<th><%= h %></th>
|
|
43
|
+
<% end %>
|
|
44
|
+
</tr>
|
|
45
|
+
</thead>
|
|
46
|
+
<tbody>
|
|
47
|
+
<% body_rows.each_with_index do |row, idx| %>
|
|
48
|
+
<tr>
|
|
49
|
+
<td class="ck-csv-table__rownum"><%= idx + 1 %></td>
|
|
50
|
+
<% headers.each_with_index do |_, i| %>
|
|
51
|
+
<td><span class="ck-csv-cell"><%= row[i] %></span></td>
|
|
52
|
+
<% end %>
|
|
53
|
+
</tr>
|
|
54
|
+
<% end %>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
18
59
|
</section>
|
|
19
60
|
|
|
20
61
|
<% if @runs.any? %>
|
|
21
62
|
<section class="ck-card--spaced">
|
|
22
63
|
<p class="ck-kicker">Runs</p>
|
|
23
64
|
|
|
24
|
-
<table class="ck-results-table" style="margin-top: 0.5rem;">
|
|
65
|
+
<table class="ck-results-table ck-runs-table" style="margin-top: 0.5rem;">
|
|
25
66
|
<thead>
|
|
26
67
|
<tr>
|
|
27
68
|
<th>Run</th>
|
|
28
|
-
<th>Prompt</th>
|
|
29
69
|
<th>Responses</th>
|
|
70
|
+
<th>Metrics</th>
|
|
71
|
+
<th>Avg score</th>
|
|
72
|
+
<th>When</th>
|
|
30
73
|
<th></th>
|
|
31
74
|
</tr>
|
|
32
75
|
</thead>
|
|
33
76
|
<tbody>
|
|
34
77
|
<% @runs.each do |run| %>
|
|
35
|
-
|
|
36
|
-
<td><strong><%= run.name %></strong></td>
|
|
37
|
-
<td><%= link_to run.prompt.name, prompt_path(run.prompt), class: "ck-link" %></td>
|
|
38
|
-
<td><%= run.responses.size %></td>
|
|
39
|
-
<td class="ck-results-table__arrow">→</td>
|
|
40
|
-
</tr>
|
|
78
|
+
<%= render "completion_kit/runs/row", run: run %>
|
|
41
79
|
<% end %>
|
|
42
80
|
</tbody>
|
|
43
81
|
</table>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="ck-rubric-row">
|
|
32
32
|
<div class="ck-rubric-row__stars">
|
|
33
33
|
<% 5.times do |i| %>
|
|
34
|
-
<svg viewBox="0 0 24 24" width="
|
|
34
|
+
<svg viewBox="0 0 24 24" width="16" height="16" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
|
35
35
|
<% end %>
|
|
36
36
|
<input type="hidden" name="metric[rubric_bands][<%= index %>][stars]" value="<%= band["stars"] %>">
|
|
37
37
|
</div>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
</section>
|
|
10
10
|
|
|
11
11
|
<% if @metrics.any? %>
|
|
12
|
-
<table class="ck-results-table">
|
|
12
|
+
<table class="ck-results-table ck-metrics-table">
|
|
13
13
|
<thead>
|
|
14
14
|
<tr>
|
|
15
15
|
<th>Name</th>
|
|
@@ -23,7 +23,20 @@
|
|
|
23
23
|
<tr onclick="window.location='<%= metric_path(metric) %>'" style="cursor: pointer;">
|
|
24
24
|
<td><strong><%= metric.name %></strong></td>
|
|
25
25
|
<td class="ck-meta-copy"><%= truncate(metric.instruction.to_s, length: 90).presence || "—" %></td>
|
|
26
|
-
<td
|
|
26
|
+
<td>
|
|
27
|
+
<% groups = metric.metric_groups %>
|
|
28
|
+
<% if groups.any? %>
|
|
29
|
+
<div class="ck-metrics-table__groups">
|
|
30
|
+
<% groups.each do |g| %>
|
|
31
|
+
<%= link_to metric_group_path(g), class: "ck-metric-group-pill ck-metric-group-pill--active", onclick: "event.stopPropagation();" do %>
|
|
32
|
+
<span class="ck-metric-group-pill__label"><%= g.name %></span>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% else %>
|
|
37
|
+
<span class="ck-metrics-table__dim">—</span>
|
|
38
|
+
<% end %>
|
|
39
|
+
</td>
|
|
27
40
|
<td class="ck-results-table__arrow">→</td>
|
|
28
41
|
</tr>
|
|
29
42
|
<% end %>
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<div class="ck-rubric-row ck-rubric-row--display">
|
|
27
27
|
<div class="ck-rubric-row__stars">
|
|
28
28
|
<% 5.times do |i| %>
|
|
29
|
-
<svg viewBox="0 0 24 24" width="
|
|
29
|
+
<svg viewBox="0 0 24 24" width="16" height="16" stroke-width="1.75" class="ck-star <%= i < band["stars"] ? "ck-star--filled" : "ck-star--empty" %>"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
|
30
30
|
<% end %>
|
|
31
31
|
</div>
|
|
32
32
|
<div class="ck-rubric-row__fields">
|