completion-kit 0.4.2 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/completion_kit/application.css +850 -69
  3. data/app/controllers/completion_kit/runs_controller.rb +31 -18
  4. data/app/controllers/completion_kit/suggestions_controller.rb +24 -0
  5. data/app/jobs/completion_kit/generate_row_job.rb +7 -0
  6. data/app/jobs/completion_kit/judge_review_job.rb +2 -0
  7. data/app/jobs/completion_kit/model_discovery_job.rb +9 -4
  8. data/app/models/completion_kit/dataset.rb +9 -0
  9. data/app/models/completion_kit/provider_credential.rb +1 -1
  10. data/app/models/completion_kit/response.rb +7 -0
  11. data/app/models/completion_kit/run.rb +22 -1
  12. data/app/services/completion_kit/anthropic_client.rb +33 -14
  13. data/app/services/completion_kit/model_discovery_service.rb +35 -9
  14. data/app/services/completion_kit/ollama_client.rb +31 -10
  15. data/app/services/completion_kit/open_ai_client.rb +35 -13
  16. data/app/services/completion_kit/open_router_client.rb +34 -13
  17. data/app/services/completion_kit/worker_health.rb +4 -1
  18. data/app/views/completion_kit/datasets/index.html.erb +1 -1
  19. data/app/views/completion_kit/datasets/show.html.erb +47 -9
  20. data/app/views/completion_kit/metrics/_form.html.erb +1 -1
  21. data/app/views/completion_kit/metrics/index.html.erb +15 -2
  22. data/app/views/completion_kit/metrics/show.html.erb +1 -1
  23. data/app/views/completion_kit/prompts/index.html.erb +27 -8
  24. data/app/views/completion_kit/prompts/show.html.erb +6 -36
  25. data/app/views/completion_kit/provider_credentials/_discovery_status.html.erb +4 -2
  26. data/app/views/completion_kit/provider_credentials/_models_card.html.erb +1 -1
  27. data/app/views/completion_kit/provider_credentials/index.html.erb +1 -1
  28. data/app/views/completion_kit/runs/_actions.html.erb +3 -0
  29. data/app/views/completion_kit/runs/_form.html.erb +114 -20
  30. data/app/views/completion_kit/runs/_response_row.html.erb +58 -35
  31. data/app/views/completion_kit/runs/_row.html.erb +50 -0
  32. data/app/views/completion_kit/runs/_sort_toolbar.html.erb +5 -4
  33. data/app/views/completion_kit/runs/_status_header.html.erb +3 -2
  34. data/app/views/completion_kit/runs/_status_panel.html.erb +55 -21
  35. data/app/views/completion_kit/runs/index.html.erb +4 -16
  36. data/app/views/completion_kit/runs/show.html.erb +110 -16
  37. data/app/views/completion_kit/suggestions/show.html.erb +65 -0
  38. data/app/views/layouts/completion_kit/application.html.erb +71 -0
  39. data/config/routes.rb +8 -2
  40. data/db/migrate/20260507000001_add_discovery_error_to_provider_credentials.rb +5 -0
  41. data/db/migrate/20260507150000_add_temperature_ignored_to_runs.rb +5 -0
  42. data/lib/completion_kit/version.rb +1 -1
  43. metadata +7 -3
  44. data/app/views/completion_kit/runs/suggestion.html.erb +0 -47
@@ -1,6 +1,6 @@
1
1
  module CompletionKit
2
2
  class RunsController < ApplicationController
3
- before_action :set_run, only: [:show, :edit, :update, :destroy, :generate, :suggest, :suggestion, :apply_suggestion, :retry_failures]
3
+ before_action :set_run, only: [:show, :edit, :update, :destroy, :generate, :suggest, :retry_failures, :rerun, :refresh_status]
4
4
  before_action :load_form_collections, only: [:new, :edit, :create, :update]
5
5
 
6
6
  def index
@@ -72,21 +72,44 @@ module CompletionKit
72
72
  end
73
73
  end
74
74
 
75
+ def rerun
76
+ new_run = Run.create!(
77
+ prompt_id: @run.prompt_id,
78
+ dataset_id: @run.dataset_id,
79
+ judge_model: @run.judge_model,
80
+ temperature: @run.temperature,
81
+ status: "pending"
82
+ )
83
+ new_run.replace_metrics!(@run.metric_ids)
84
+ if new_run.start!
85
+ redirect_to run_path(new_run), notice: "Re-running with the same configuration."
86
+ else
87
+ redirect_to run_path(new_run), alert: new_run.failure_summary || "Could not start the new run."
88
+ end
89
+ end
90
+
91
+ def refresh_status
92
+ respond_to do |format|
93
+ format.turbo_stream do
94
+ render turbo_stream: turbo_stream.replace(
95
+ "run_status_header",
96
+ partial: "completion_kit/runs/status_header",
97
+ locals: { run: @run }
98
+ )
99
+ end
100
+ end
101
+ end
102
+
75
103
  def suggest
76
104
  service = PromptImprovementService.new(@run)
77
105
  result = service.suggest
78
- @run.suggestions.create!(
106
+ suggestion = @run.suggestions.create!(
79
107
  prompt: @run.prompt,
80
108
  reasoning: result["reasoning"],
81
109
  suggested_template: result["suggested_template"],
82
110
  original_template: result["original_template"]
83
111
  )
84
- redirect_to suggestion_run_path(@run)
85
- end
86
-
87
- def suggestion
88
- @suggestion = @run.suggestions.order(created_at: :desc).first
89
- return redirect_to run_path(@run), alert: "No suggestion available. Generate one first." unless @suggestion
112
+ redirect_to suggestion_path(suggestion, from: "run")
90
113
  end
91
114
 
92
115
  def retry_failures
@@ -115,16 +138,6 @@ module CompletionKit
115
138
  redirect_to run_path(@run)
116
139
  end
117
140
 
118
- def apply_suggestion
119
- suggestion = @run.suggestions.order(created_at: :desc).first
120
- return redirect_to run_path(@run), alert: "No suggestion to apply." unless suggestion
121
-
122
- new_prompt = @run.prompt.clone_as_new_version(template: suggestion.suggested_template)
123
- new_prompt.publish!
124
- suggestion.update!(applied_at: Time.current)
125
- redirect_to prompt_path(new_prompt), notice: "Suggestion applied."
126
- end
127
-
128
141
  private
129
142
 
130
143
  def set_run
@@ -0,0 +1,24 @@
1
+ module CompletionKit
2
+ class SuggestionsController < ApplicationController
3
+ before_action :set_suggestion
4
+
5
+ def show
6
+ @run = @suggestion.run
7
+ @from = params[:from] == "run" ? "run" : "prompt"
8
+ end
9
+
10
+ def apply
11
+ run = @suggestion.run
12
+ new_prompt = run.prompt.clone_as_new_version(template: @suggestion.suggested_template)
13
+ new_prompt.publish!
14
+ @suggestion.update!(applied_at: Time.current)
15
+ redirect_to prompt_path(new_prompt), notice: "Suggestion applied."
16
+ end
17
+
18
+ private
19
+
20
+ def set_suggestion
21
+ @suggestion = Suggestion.find(params[:id])
22
+ end
23
+ end
24
+ end
@@ -49,6 +49,11 @@ module CompletionKit
49
49
  raise ConfigurationError, client.configuration_errors.join(", ") unless client.configured?
50
50
 
51
51
  text = client.generate_completion(rendered, model: prompt.llm_model, temperature: run.temperature)
52
+ raise StandardError, text.to_s.sub(/\AError:\s*/, "") if text.to_s.start_with?("Error:")
53
+
54
+ if client.respond_to?(:temperature_dropped?) && client.temperature_dropped? && !run.temperature_ignored?
55
+ run.update_columns(temperature_ignored: true)
56
+ end
52
57
 
53
58
  response.update!(
54
59
  status: "succeeded",
@@ -56,6 +61,7 @@ module CompletionKit
56
61
  error_provider: nil, error_class: nil, error_status: nil, error_message: nil
57
62
  )
58
63
  run.send(:broadcast_response_update, response)
64
+ run.send(:broadcast_progress)
59
65
 
60
66
  if run.judge_configured?
61
67
  run.metrics.each do |metric|
@@ -88,6 +94,7 @@ module CompletionKit
88
94
  error_message: error.message.to_s.truncate(2000)
89
95
  )
90
96
  response.run&.send(:broadcast_response_update, response)
97
+ response.run&.send(:broadcast_progress)
91
98
  end
92
99
 
93
100
  def provider_for(response)
@@ -71,6 +71,7 @@ module CompletionKit
71
71
  review.save!
72
72
 
73
73
  run.send(:broadcast_response_update, response)
74
+ run.send(:broadcast_progress)
74
75
  enqueue_completion_check
75
76
  end
76
77
 
@@ -93,6 +94,7 @@ module CompletionKit
93
94
  )
94
95
  review.save!(validate: false)
95
96
  response.run&.send(:broadcast_response_update, response)
97
+ response.run&.send(:broadcast_progress)
96
98
  end
97
99
 
98
100
  def provider_for(response)
@@ -17,9 +17,9 @@ module CompletionKit
17
17
 
18
18
  discard_on ActiveJob::DeserializationError
19
19
 
20
- rescue_from(StandardError) do |_error|
20
+ rescue_from(StandardError) do |error|
21
21
  credential = ProviderCredential.find(arguments.first)
22
- credential.update_columns(discovery_status: "failed")
22
+ credential.update_columns(discovery_status: "failed", discovery_error: error.message.to_s.truncate(500))
23
23
  credential.reload
24
24
  credential.broadcast_discovery_progress
25
25
  end
@@ -28,7 +28,12 @@ module CompletionKit
28
28
  credential = ProviderCredential.find_by(id: provider_credential_id)
29
29
  return unless credential
30
30
 
31
- credential.update_columns(discovery_status: "discovering", discovery_current: 0, discovery_total: 0)
31
+ credential.update_columns(
32
+ discovery_status: "discovering",
33
+ discovery_current: 0,
34
+ discovery_total: 0,
35
+ discovery_error: nil
36
+ )
32
37
  credential.reload
33
38
  credential.broadcast_discovery_progress
34
39
 
@@ -39,7 +44,7 @@ module CompletionKit
39
44
  credential.broadcast_discovery_progress
40
45
  end
41
46
 
42
- credential.update_columns(discovery_status: "completed", updated_at: Time.current)
47
+ credential.update_columns(discovery_status: "completed", discovery_error: nil, updated_at: Time.current)
43
48
  credential.reload
44
49
  credential.broadcast_discovery_complete
45
50
  end
@@ -20,5 +20,14 @@ module CompletionKit
20
20
  rescue ::CSV::MalformedCSVError
21
21
  0
22
22
  end
23
+
24
+ def headers
25
+ return [] if csv_data.blank?
26
+
27
+ require "csv"
28
+ ::CSV.parse(csv_data.lines.first.to_s).first.to_a.map(&:to_s).map(&:strip)
29
+ rescue ::CSV::MalformedCSVError
30
+ []
31
+ end
23
32
  end
24
33
  end
@@ -56,7 +56,7 @@ module CompletionKit
56
56
  def judge_count
57
57
  model_ids = Model.where(provider: provider).pluck(:model_id)
58
58
  return 0 if model_ids.empty?
59
- Run.where(judge_model: model_ids).count
59
+ Run.where(judge_model: model_ids).distinct.count(:judge_model)
60
60
  end
61
61
 
62
62
  def last_used_at
@@ -43,6 +43,13 @@ module CompletionKit
43
43
  reviews.any? { |r| r.ai_score.present? }
44
44
  end
45
45
 
46
+ def fully_reviewed?
47
+ metric_ids = run.metric_ids
48
+ return true if metric_ids.empty?
49
+ reviewed_metric_ids = reviews.where(status: Review::TERMINAL_STATUSES).pluck(:metric_id).uniq
50
+ (metric_ids - reviewed_metric_ids).empty?
51
+ end
52
+
46
53
  def error_payload
47
54
  return nil if error_class.blank?
48
55
  { provider: error_provider, class: error_class, status: error_status, message: error_message }
@@ -13,10 +13,20 @@ module CompletionKit
13
13
 
14
14
  validates :name, presence: true
15
15
  validates :status, inclusion: { in: STATUSES }
16
+ validate :dataset_supplies_prompt_variables
16
17
 
17
18
  before_validation :set_default_status, on: :create
18
19
  before_validation :set_auto_name, on: :create
19
20
 
21
+ def missing_dataset_variables
22
+ return [] unless prompt
23
+ vars = prompt.variables
24
+ return [] if vars.empty?
25
+ return vars if dataset.nil?
26
+
27
+ vars - dataset.headers
28
+ end
29
+
20
30
  def mark_completed!
21
31
  update!(status: "completed")
22
32
  broadcast_ui
@@ -236,7 +246,7 @@ module CompletionKit
236
246
  broadcast_replace_to(
237
247
  "completion_kit_run_#{id}",
238
248
  target: "run_responses",
239
- html: '<div id="run_responses"></div>'
249
+ html: '<tbody id="run_responses"></tbody>'
240
250
  )
241
251
  end
242
252
 
@@ -267,5 +277,16 @@ module CompletionKit
267
277
  count = Run.where(prompt_id: prompt_id).count + 1
268
278
  self.name = "#{prompt.name} — v#{prompt.version_number} ##{count}"
269
279
  end
280
+
281
+ def dataset_supplies_prompt_variables
282
+ missing = missing_dataset_variables
283
+ return if missing.empty?
284
+
285
+ if dataset.nil?
286
+ errors.add(:dataset_id, "is required: prompt uses #{missing.join(', ')}")
287
+ else
288
+ errors.add(:dataset_id, "is missing columns required by the prompt: #{missing.join(', ')}")
289
+ end
290
+ end
270
291
  end
271
292
  end
@@ -5,28 +5,25 @@ module CompletionKit
5
5
  { id: "claude-3-5-haiku-latest", name: "Claude 3.5 Haiku" }
6
6
  ].freeze
7
7
 
8
+ def temperature_dropped?
9
+ @temperature_dropped == true
10
+ end
11
+
8
12
  def generate_completion(prompt, options = {})
13
+ @temperature_dropped = false
9
14
  return "Error: API key not configured" unless configured?
10
15
 
11
16
  model = options[:model] || "claude-3-7-sonnet-latest"
12
17
  max_tokens = options[:max_tokens] || 1000
13
18
  temperature = options[:temperature] || 0.7
14
19
 
15
- response = build_connection("https://api.anthropic.com").post do |req|
16
- req.url "/v1/messages"
17
- req.headers["Content-Type"] = "application/json"
18
- req.headers["x-api-key"] = api_key
19
- req.headers["anthropic-version"] = "2023-06-01"
20
- req.body = {
21
- model: model,
22
- messages: [
23
- { role: "user", content: prompt }
24
- ],
25
- max_tokens: max_tokens,
26
- temperature: temperature
27
- }.to_json
20
+ response = post_messages(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
21
+
22
+ if response.status == 400 && temperature_unsupported?(response.body)
23
+ @temperature_dropped = true
24
+ response = post_messages(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
28
25
  end
29
-
26
+
30
27
  if response.status == 429
31
28
  raise CompletionKit::RateLimitError.new(
32
29
  response.body.to_s.truncate(500),
@@ -82,5 +79,27 @@ module CompletionKit
82
79
  def api_key
83
80
  @config[:api_key] || ENV["ANTHROPIC_API_KEY"]
84
81
  end
82
+
83
+ def post_messages(model:, prompt:, max_tokens:, temperature:)
84
+ body = {
85
+ model: model,
86
+ messages: [{ role: "user", content: prompt }],
87
+ max_tokens: max_tokens
88
+ }
89
+ body[:temperature] = temperature unless temperature.nil?
90
+
91
+ build_connection("https://api.anthropic.com").post do |req|
92
+ req.url "/v1/messages"
93
+ req.headers["Content-Type"] = "application/json"
94
+ req.headers["x-api-key"] = api_key
95
+ req.headers["anthropic-version"] = "2023-06-01"
96
+ req.body = body.to_json
97
+ end
98
+ end
99
+
100
+ def temperature_unsupported?(body)
101
+ s = body.to_s
102
+ s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported"))
103
+ end
85
104
  end
86
105
  end
@@ -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]
@@ -36,11 +38,31 @@ module CompletionKit
36
38
  end
37
39
  end
38
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
+
39
61
  def fetch_openai_models
40
62
  response = fetch_connection("https://api.openai.com").get("/v1/models") do |req|
41
63
  req.headers["Authorization"] = "Bearer #{@api_key}"
42
64
  end
43
- return [] unless response.success?
65
+ raise_fetch_error!(response) unless response.success?
44
66
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: nil } }
45
67
  end
46
68
 
@@ -49,7 +71,7 @@ module CompletionKit
49
71
  req.headers["x-api-key"] = @api_key
50
72
  req.headers["anthropic-version"] = "2023-06-01"
51
73
  end
52
- return [] unless response.success?
74
+ raise_fetch_error!(response) unless response.success?
53
75
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["display_name"] } }
54
76
  end
55
77
 
@@ -59,7 +81,7 @@ module CompletionKit
59
81
  req.headers["HTTP-Referer"] = "https://completionkit.com"
60
82
  req.headers["X-Title"] = "CompletionKit"
61
83
  end
62
- return [] unless response.success?
84
+ raise_fetch_error!(response) unless response.success?
63
85
  JSON.parse(response.body).fetch("data", []).filter_map do |entry|
64
86
  next nil if entry["deprecated"] == true
65
87
  context_length = entry["context_length"].to_i
@@ -69,12 +91,12 @@ module CompletionKit
69
91
  end
70
92
 
71
93
  def fetch_ollama_models
72
- return [] if @api_endpoint.nil?
94
+ raise DiscoveryError, "Ollama endpoint URL is required" if @api_endpoint.blank?
73
95
  base_url = @api_endpoint.to_s.delete_suffix("/")
74
96
  response = fetch_connection(base_url).get("/models") do |req|
75
97
  req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
76
98
  end
77
- return [] unless response.success?
99
+ raise_fetch_error!(response) unless response.success?
78
100
  JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
79
101
  end
80
102
 
@@ -147,14 +169,18 @@ module CompletionKit
147
169
  end
148
170
 
149
171
  def probe_generation(model)
150
- response = send_probe(model.model_id, "Say hello", 65536)
172
+ probe_input = "Reply with exactly this token and nothing else: PING-OK"
173
+ response = send_probe(model.model_id, probe_input, 65536)
151
174
  if response.success?
152
- text = extract_text(response)
153
- if text.present?
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")
154
180
  model.supports_generation = true
155
181
  else
156
182
  model.supports_generation = false
157
- model.generation_error = "Empty response"
183
+ model.generation_error = "Did not follow text completion instruction (likely non-text-output model): #{text.truncate(200)}"
158
184
  end
159
185
  else
160
186
  model.supports_generation = false
@@ -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 = build_connection(api_endpoint).post do |req|
11
- req.url "/v1/completions"
12
- req.headers["Content-Type"] = "application/json"
13
- req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
14
- req.body = {
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 = build_connection("https://api.openai.com").post do |req|
17
- req.url "/v1/responses"
18
- req.headers["Content-Type"] = "application/json"
19
- req.headers["Authorization"] = "Bearer #{api_key}"
20
- req.body = {
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"][0]["content"][0]["text"].strip
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/api/v1".freeze
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 = build_connection(BASE_URL, timeout: 30, open_timeout: 5).post do |req|
15
- req.url "/chat/completions"
16
- req.headers["Content-Type"] = "application/json"
17
- req.headers["Authorization"] = "Bearer #{api_key}"
18
- req.headers["HTTP-Referer"] = REFERER
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.where("last_heartbeat_at > ?", HEARTBEAT_THRESHOLD.ago).exists?
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-%m-%d") %></td>
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">&rarr;</td>
30
30
  </tr>
31
31
  <% end %>