completion-kit 0.28.44 → 0.28.45

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -6
  3. data/app/assets/stylesheets/completion_kit/application.css +5 -0
  4. data/app/controllers/completion_kit/agreements_controller.rb +1 -5
  5. data/app/controllers/completion_kit/api/v1/base_controller.rb +5 -0
  6. data/app/controllers/completion_kit/api/v1/metric_groups_controller.rb +6 -4
  7. data/app/controllers/completion_kit/api/v1/metrics_controller.rb +1 -1
  8. data/app/controllers/completion_kit/api/v1/runs_controller.rb +9 -5
  9. data/app/controllers/completion_kit/application_controller.rb +13 -0
  10. data/app/controllers/completion_kit/mcp_controller.rb +12 -4
  11. data/app/controllers/completion_kit/metric_groups_controller.rb +6 -4
  12. data/app/controllers/completion_kit/provider_credentials_controller.rb +19 -1
  13. data/app/controllers/completion_kit/runs_controller.rb +13 -8
  14. data/app/jobs/completion_kit/generate_row_job.rb +2 -1
  15. data/app/jobs/completion_kit/judge_review_job.rb +1 -0
  16. data/app/jobs/completion_kit/metric_suggestion_job.rb +1 -2
  17. data/app/jobs/completion_kit/model_discovery_job.rb +28 -13
  18. data/app/models/completion_kit/metric_group.rb +4 -2
  19. data/app/models/completion_kit/metric_group_membership.rb +1 -1
  20. data/app/models/completion_kit/prompt.rb +7 -6
  21. data/app/models/completion_kit/provider_credential.rb +19 -2
  22. data/app/models/completion_kit/run.rb +77 -14
  23. data/app/models/completion_kit/run_metric.rb +1 -1
  24. data/app/models/concerns/completion_kit/metric_selection.rb +35 -0
  25. data/app/services/completion_kit/api_config.rb +23 -7
  26. data/app/services/completion_kit/azure_foundry_client.rb +4 -2
  27. data/app/services/completion_kit/csv_processor.rb +4 -6
  28. data/app/services/completion_kit/json_object.rb +14 -0
  29. data/app/services/completion_kit/llm_client.rb +1 -1
  30. data/app/services/completion_kit/mcp_dispatcher.rb +1 -1
  31. data/app/services/completion_kit/mcp_tools/base.rb +2 -0
  32. data/app/services/completion_kit/mcp_tools/datasets.rb +1 -1
  33. data/app/services/completion_kit/mcp_tools/judges.rb +2 -2
  34. data/app/services/completion_kit/mcp_tools/metric_groups.rb +2 -4
  35. data/app/services/completion_kit/mcp_tools/provider_credentials.rb +3 -2
  36. data/app/services/completion_kit/mcp_tools/runs.rb +6 -6
  37. data/app/services/completion_kit/model_discovery_service.rb +10 -10
  38. data/app/services/completion_kit/ollama_client.rb +4 -2
  39. data/app/services/completion_kit/promptfoo_importer.rb +1 -1
  40. data/app/services/completion_kit/provider_endpoint.rb +18 -9
  41. data/app/views/completion_kit/agreements/_buttons.html.erb +1 -1
  42. data/app/views/completion_kit/agreements/_trust_panel.html.erb +1 -2
  43. data/app/views/completion_kit/api_reference/_body.html.erb +4 -3
  44. data/app/views/completion_kit/dashboard/show.html.erb +1 -1
  45. data/app/views/completion_kit/datasets/show.html.erb +5 -0
  46. data/app/views/completion_kit/metric_groups/_form.html.erb +4 -1
  47. data/app/views/completion_kit/prompts/show.html.erb +5 -0
  48. data/app/views/completion_kit/provider_credentials/_form.html.erb +24 -1
  49. data/app/views/completion_kit/provider_credentials/edit.html.erb +1 -1
  50. data/app/views/completion_kit/provider_credentials/index.html.erb +1 -2
  51. data/app/views/completion_kit/responses/show.html.erb +1 -1
  52. data/app/views/completion_kit/runs/_form.html.erb +1 -1
  53. data/app/views/completion_kit/runs/index.html.erb +1 -1
  54. data/config/locales/en.yml +12 -0
  55. data/lib/completion_kit/errors.rb +30 -0
  56. data/lib/completion_kit/version.rb +1 -1
  57. data/lib/completion_kit.rb +1 -1
  58. data/lib/generators/completion_kit/templates/initializer.rb +9 -0
  59. data/lib/tasks/completion_kit_runs.rake +2 -2
  60. metadata +4 -2
  61. data/app/models/completion_kit/mcp_session.rb +0 -54
@@ -2,6 +2,7 @@ module CompletionKit
2
2
  class Run < ApplicationRecord
3
3
  include Turbo::Broadcastable
4
4
  include CompletionKit::Taggable
5
+ include CompletionKit::MetricSelection
5
6
 
6
7
  STATUSES = %w[pending running completed failed].freeze
7
8
  INSERT_BATCH_SIZE = 1000
@@ -21,14 +22,17 @@ module CompletionKit
21
22
  has_many :dashboard_dismissals, as: :dismissable, dependent: :destroy
22
23
 
23
24
  attr_writer :avg_score, :check_pass_rate, :metric_averages, :response_count
25
+ attr_accessor :cloned_from_judge_model
24
26
 
25
27
  validates :name, presence: true
26
28
  validates :status, inclusion: { in: STATUSES }
27
29
  validates :max_tokens, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
28
30
  validates :judge_temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
29
- validate :dataset_supplies_prompt_variables
30
- validate :judge_only_run_supplies_output_column
31
+ validate :references_in_scope
32
+ validate :dataset_supplies_prompt_variables, unless: :reference_out_of_scope?
33
+ validate :judge_only_run_supplies_output_column, unless: :reference_out_of_scope?
31
34
  validate :dataset_supplies_expected_column
35
+ validate :judge_model_servable, if: :judge_model_newly_selected?
32
36
 
33
37
  before_validation :set_default_status, on: :create
34
38
  before_validation :set_auto_name, on: :create
@@ -221,9 +225,10 @@ module CompletionKit
221
225
  end
222
226
 
223
227
  def replace_metrics!(metric_ids)
224
- return unless metric_ids
228
+ return if metric_ids.nil?
229
+
225
230
  run_metrics.delete_all
226
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
231
+ normalize_metric_ids(metric_ids).each_with_index do |metric_id, index|
227
232
  run_metrics.create!(metric_id: metric_id, position: index + 1)
228
233
  end
229
234
  end
@@ -329,12 +334,13 @@ module CompletionKit
329
334
  return fail_with_summary!("Dataset has no \"#{column}\" column")
330
335
  end
331
336
  else
332
- client = LlmClient.for_model(prompt.llm_model, ApiConfig.for_model(prompt.llm_model))
333
- unless client.configured?
334
- return fail_with_summary!("LLM API not configured: #{client.configuration_errors.join(', ')}")
335
- end
337
+ generation_error = generation_client_error
338
+ return fail_with_summary!(generation_error) if generation_error
336
339
  end
337
340
 
341
+ judge_error = judge_model_error
342
+ return fail_with_summary!(judge_error) if judge_error
343
+
338
344
  begin
339
345
  transaction do
340
346
  responses.destroy_all
@@ -395,7 +401,7 @@ module CompletionKit
395
401
 
396
402
  response_ids = responses.order(:row_index).pluck(:id)
397
403
  if judge
398
- judge_metrics = llm_judge_configured? ? llm_metrics.to_a : []
404
+ judge_metrics = judge_model.present? ? llm_metrics.to_a : []
399
405
  chk_metrics = check_metrics.to_a
400
406
  review_jobs = response_ids.flat_map do |rid|
401
407
  judge_metrics.map { |m| JudgeReviewJob.new(rid, m.id, id) } +
@@ -424,6 +430,11 @@ module CompletionKit
424
430
  end
425
431
 
426
432
  def regrade!
433
+ judge_error = judge_model_error
434
+ if judge_error
435
+ errors.add(:base, judge_error)
436
+ return false
437
+ end
427
438
  return false if metrics.empty? || !gradable?
428
439
 
429
440
  eligible_responses = responses.where(status: "succeeded")
@@ -447,7 +458,7 @@ module CompletionKit
447
458
  update!(status: "running", failure_summary: nil, error_message: nil, judge_temperature_ignored: false)
448
459
 
449
460
  response_ids.each do |rid|
450
- llm_metrics.each { |m| JudgeReviewJob.perform_later(rid, m.id, id) } if llm_judge_configured?
461
+ llm_metrics.each { |m| JudgeReviewJob.perform_later(rid, m.id, id) } if judge_model.present?
451
462
  check_metrics.each { |m| CheckReviewJob.perform_later(rid, m.id, id) }
452
463
  end
453
464
  RunCompletionCheckJob.perform_later(id)
@@ -479,7 +490,8 @@ module CompletionKit
479
490
  end
480
491
 
481
492
  def rerun!
482
- new_run = Run.create!(
493
+ ensure_models_resolve!
494
+ Run.create!(
483
495
  prompt_id: prompt_id,
484
496
  dataset_id: dataset_id,
485
497
  judge_model: judge_model,
@@ -489,10 +501,10 @@ module CompletionKit
489
501
  output_column: output_column,
490
502
  expected_column: expected_column,
491
503
  tag_names: tag_names,
492
- status: "pending"
504
+ status: "pending",
505
+ cloned_from_judge_model: judge_model,
506
+ selected_metric_ids: run_metrics.pluck(:metric_id)
493
507
  )
494
- new_run.replace_metrics!(metric_ids)
495
- new_run
496
508
  end
497
509
 
498
510
  def retry_failures!(only: nil)
@@ -686,6 +698,40 @@ module CompletionKit
686
698
  Rails.error.report(e, handled: true, context: { hook: "on_run_started", run_id: id })
687
699
  end
688
700
 
701
+ def generation_client_error
702
+ client = LlmClient.for_model(prompt.llm_model, ApiConfig.for_model(prompt.llm_model))
703
+ "LLM API not configured: #{client.configuration_errors.join(', ')}" unless client.configured?
704
+ rescue UnknownModelError => e
705
+ e.message
706
+ end
707
+
708
+ def judge_model_error
709
+ return unless judge_model.present? && llm_metrics.exists?
710
+ return if ApiConfig.provider_for_model(judge_model)
711
+
712
+ %(No configured provider serves judge model "#{judge_model}")
713
+ end
714
+
715
+ def ensure_models_resolve!
716
+ raise UnknownModelError, prompt.llm_model unless judge_only? || ApiConfig.provider_for_model(prompt.llm_model)
717
+
718
+ judge_error = judge_model_error
719
+ raise ConfigurationError, judge_error if judge_error
720
+ end
721
+
722
+ def judge_model_newly_selected?
723
+ return false if judge_model.blank?
724
+ return judge_model != cloned_from_judge_model if new_record? && cloned_from_judge_model.present?
725
+
726
+ judge_model_changed?
727
+ end
728
+
729
+ def judge_model_servable
730
+ return if ApiConfig.servable_model?(judge_model)
731
+
732
+ errors.add(:judge_model, %("#{judge_model}" is not served by any configured provider))
733
+ end
734
+
689
735
  def fail_with_summary!(message)
690
736
  errors.add(:base, message)
691
737
  if persisted?
@@ -736,6 +782,23 @@ module CompletionKit
736
782
  end
737
783
  end
738
784
 
785
+ def prompt_out_of_scope?
786
+ will_save_change_to_prompt_id? && prompt_id.present? && prompt.nil?
787
+ end
788
+
789
+ def dataset_out_of_scope?
790
+ will_save_change_to_dataset_id? && dataset_id.present? && dataset.nil?
791
+ end
792
+
793
+ def reference_out_of_scope?
794
+ prompt_out_of_scope? || dataset_out_of_scope?
795
+ end
796
+
797
+ def references_in_scope
798
+ errors.add(:prompt_id, "not found") if prompt_out_of_scope?
799
+ errors.add(:dataset_id, "not found") if dataset_out_of_scope?
800
+ end
801
+
739
802
  def dataset_supplies_prompt_variables
740
803
  missing = missing_dataset_variables
741
804
  return if missing.empty?
@@ -1,6 +1,6 @@
1
1
  module CompletionKit
2
2
  class RunMetric < ApplicationRecord
3
3
  belongs_to :run
4
- belongs_to :metric
4
+ belongs_to :metric, optional: false
5
5
  end
6
6
  end
@@ -0,0 +1,35 @@
1
+ module CompletionKit
2
+ module MetricSelection
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validate :selected_metrics_in_scope, if: -> { @metric_selection_assigned }
7
+ after_save :attach_selected_metrics, if: -> { @metric_selection_assigned }
8
+ end
9
+
10
+ def selected_metric_ids=(ids)
11
+ @metric_selection_assigned = !ids.nil?
12
+ @selected_metric_ids = ids
13
+ end
14
+
15
+ def selected_metric_ids
16
+ @metric_selection_assigned ? normalize_metric_ids(@selected_metric_ids) : metric_ids
17
+ end
18
+
19
+ private
20
+
21
+ def normalize_metric_ids(ids)
22
+ Array(ids).reject(&:blank?).map(&:to_i).uniq
23
+ end
24
+
25
+ def selected_metrics_in_scope
26
+ wanted = selected_metric_ids
27
+ errors.add(:metric_ids, "include one that isn't available") if CompletionKit::Metric.where(id: wanted).count < wanted.size
28
+ end
29
+
30
+ def attach_selected_metrics
31
+ replace_metrics!(@selected_metric_ids)
32
+ @metric_selection_assigned = false
33
+ end
34
+ end
35
+ end
@@ -39,14 +39,14 @@ module CompletionKit
39
39
  available_match = available_models.find { |model| model[:id] == model_name.to_s }
40
40
  return available_match[:provider] if available_match
41
41
 
42
- guess = case model_name.to_s
43
- when /\Agpt-/ then "openai"
44
- when /\Aclaude-/ then "anthropic"
45
- end
46
- configured = ProviderCredential.distinct.pluck(:provider)
47
- return guess if configured.empty?
42
+ guessed_provider(model_name)
43
+ end
48
44
 
49
- guess if guess && configured.include?(guess)
45
+ def self.servable_model?(model_name)
46
+ return true unless Model.for_generation.exists?
47
+ return true if Model.active.exists?(model_id: model_name.to_s)
48
+
49
+ guessed_provider(model_name).present?
50
50
  end
51
51
 
52
52
  def self.default_judge_model
@@ -58,11 +58,15 @@ module CompletionKit
58
58
  def self.valid_for_model?(model_name)
59
59
  client = LlmClient.for_model(model_name, for_model(model_name))
60
60
  client.configured?
61
+ rescue UnknownModelError
62
+ false
61
63
  end
62
64
 
63
65
  def self.errors_for_model(model_name)
64
66
  client = LlmClient.for_model(model_name, for_model(model_name))
65
67
  client.configuration_errors
68
+ rescue UnknownModelError => e
69
+ [e.message]
66
70
  end
67
71
 
68
72
  def self.available_models(provider: nil, scope: :generation)
@@ -90,5 +94,17 @@ module CompletionKit
90
94
  []
91
95
  end.uniq { |model| model[:id] }
92
96
  end
97
+
98
+ def self.guessed_provider(model_name)
99
+ guess = case model_name.to_s
100
+ when /\Agpt-/ then "openai"
101
+ when /\Aclaude-/ then "anthropic"
102
+ end
103
+ configured = ProviderCredential.distinct.pluck(:provider)
104
+ return guess if configured.empty?
105
+
106
+ guess if guess && configured.include?(guess)
107
+ end
108
+ private_class_method :guessed_provider
93
109
  end
94
110
  end
@@ -35,7 +35,7 @@ module CompletionKit
35
35
  end
36
36
 
37
37
  if response.success?
38
- data = JSON.parse(response.body)
38
+ data = JsonObject.parse(response.body)
39
39
  content = data.dig("choices", 0, "message", "content").to_s.strip
40
40
  return "Error: model returned empty content" if content.empty?
41
41
  content
@@ -48,6 +48,8 @@ module CompletionKit
48
48
  raise
49
49
  rescue ProviderEndpoint::UnsafeEndpoint => e
50
50
  "Error: API endpoint #{e.message}"
51
+ rescue JSON::ParserError => e
52
+ "Error: #{EndpointError.from(e).message}"
51
53
  rescue => e
52
54
  "Error: #{e.message}"
53
55
  end
@@ -61,7 +63,7 @@ module CompletionKit
61
63
  end
62
64
  return [] unless response.success?
63
65
 
64
- body = JSON.parse(response.body)
66
+ body = JsonObject.parse(response.body)
65
67
  if foundry_project?
66
68
  body.fetch("value", []).map { |d| { id: d["name"], name: d["name"] } }
67
69
  else
@@ -53,13 +53,11 @@ module CompletionKit
53
53
  end
54
54
 
55
55
  def self.apply_variables(prompt, variables)
56
- result = prompt.template.dup
56
+ values = variables.to_h.transform_keys(&:to_s)
57
+ return prompt.template.dup if values.empty?
57
58
 
58
- variables.each do |name, value|
59
- result.gsub!(/\{\{\s*#{Regexp.escape(name.to_s)}\s*\}\}/, value.to_s)
60
- end
61
-
62
- result
59
+ names = values.keys.map { |name| Regexp.escape(name) }.join("|")
60
+ prompt.template.gsub(/\{\{\s*(#{names})\s*\}\}/) { values[Regexp.last_match(1)].to_s }
63
61
  end
64
62
  end
65
63
  end
@@ -0,0 +1,14 @@
1
+ require "json"
2
+
3
+ module CompletionKit
4
+ module JsonObject
5
+ module_function
6
+
7
+ def parse(text)
8
+ data = JSON.parse(text)
9
+ raise JSON::ParserError, "expected a JSON object, got #{data.class}" unless data.is_a?(Hash)
10
+
11
+ data
12
+ end
13
+ end
14
+ end
@@ -79,7 +79,7 @@ module CompletionKit
79
79
 
80
80
  def self.for_model(model_name, config = {})
81
81
  provider = ApiConfig.provider_for_model(model_name)
82
- raise ArgumentError, "Unsupported model: #{model_name}" unless provider
82
+ raise UnknownModelError, model_name unless provider
83
83
 
84
84
  for_provider(provider, config)
85
85
  end
@@ -7,7 +7,7 @@ module CompletionKit
7
7
 
8
8
  def self.initialize_session
9
9
  {
10
- session_id: McpSession.start!,
10
+ session_id: SecureRandom.uuid,
11
11
  protocolVersion: PROTOCOL_VERSION,
12
12
  serverInfo: {name: "CompletionKit", version: CompletionKit::VERSION},
13
13
  capabilities: {tools: {listChanged: false}}
@@ -20,6 +20,8 @@ module CompletionKit
20
20
  def call(name, arguments)
21
21
  tool = self::TOOLS.fetch(name)
22
22
  send(tool[:handler], arguments)
23
+ rescue ConfigurationError => e
24
+ error_result(e.message)
23
25
  end
24
26
 
25
27
  def text_result(data)
@@ -106,7 +106,7 @@ module CompletionKit
106
106
  rescue ProviderEndpoint::UnsafeEndpoint => e
107
107
  error_result("URL is not allowed: it #{e.message}.")
108
108
  rescue Faraday::Error => e
109
- error_result("Could not download CSV: #{e.message}")
109
+ error_result("Could not download CSV. #{EndpointError.from(e).message}")
110
110
  end
111
111
 
112
112
  def self.csv_connection(url)
@@ -41,10 +41,10 @@ module CompletionKit
41
41
  name: args["name"],
42
42
  dataset: dataset,
43
43
  judge_model: args["judge_model"],
44
- output_column: args["output_column"].presence || "actual_output"
44
+ output_column: args["output_column"].presence || "actual_output",
45
+ selected_metric_ids: [metric.id]
45
46
  )
46
47
  if run.save
47
- run.replace_metrics!([metric.id])
48
48
  text_result(run.reload.as_json)
49
49
  else
50
50
  error_result(run.errors.full_messages.join(", "))
@@ -56,10 +56,9 @@ module CompletionKit
56
56
  end
57
57
 
58
58
  def self.create(args)
59
- metric_group = CompletionKit::MetricGroup.new(args.slice("name", "description"))
59
+ metric_group = CompletionKit::MetricGroup.new(args.slice("name", "description").merge("selected_metric_ids" => args["metric_ids"]))
60
60
  metric_group.tag_names = args["tag_names"] if args.key?("tag_names")
61
61
  if metric_group.save
62
- metric_group.replace_metrics!(args["metric_ids"])
63
62
  text_result(metric_group.reload.as_json)
64
63
  else
65
64
  error_result(metric_group.errors.full_messages.join(", "))
@@ -68,8 +67,7 @@ module CompletionKit
68
67
 
69
68
  def self.update(args)
70
69
  metric_group = CompletionKit::MetricGroup.find(args["id"])
71
- if metric_group.update(args.except("id", "metric_ids").slice("name", "description"))
72
- metric_group.replace_metrics!(args["metric_ids"]) if args.key?("metric_ids")
70
+ if metric_group.update(args.slice("name", "description").merge("selected_metric_ids" => args["metric_ids"]))
73
71
  metric_group.update!(tag_names: args["tag_names"]) if args.key?("tag_names")
74
72
  text_result(metric_group.reload.as_json)
75
73
  else
@@ -29,12 +29,13 @@ module CompletionKit
29
29
  handler: :create
30
30
  },
31
31
  "provider_credentials_update" => {
32
- description: "Update a provider credential",
32
+ description: "Update a provider credential. Changing provider or api_endpoint on a credential with a stored key requires api_key in the same call, so a stored key never moves to a new provider or endpoint.",
33
33
  inputSchema: {
34
34
  type: "object",
35
35
  properties: {
36
36
  id: {type: "integer"}, provider: {type: "string"},
37
- api_key: {type: "string"}, api_endpoint: {type: "string"}, api_version: {type: "string"}
37
+ api_key: {type: "string", description: "Required when provider or api_endpoint changes and a key is stored"},
38
+ api_endpoint: {type: "string"}, api_version: {type: "string"}
38
39
  },
39
40
  required: ["id"]
40
41
  },
@@ -109,9 +109,8 @@ module CompletionKit
109
109
  end
110
110
 
111
111
  def self.create(args)
112
- run = Run.new(args.slice("name", "prompt_id", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS))
112
+ run = Run.new(args.slice("name", "prompt_id", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS).merge("selected_metric_ids" => resolve_metric_ids(args)))
113
113
  if run.save
114
- run.replace_metrics!(resolve_metric_ids(args))
115
114
  run.update!(tag_names: args["tag_names"]) if args.key?("tag_names")
116
115
  text_result(run_payload(run.reload))
117
116
  else
@@ -121,8 +120,7 @@ module CompletionKit
121
120
 
122
121
  def self.update(args)
123
122
  run = Run.find(args["id"])
124
- if run.update(args.except("id", "metric_ids", "metric_group_id", "tag_names").slice("name", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS))
125
- run.replace_metrics!(resolve_metric_ids(args)) if args.key?("metric_ids") || args["metric_group_id"].present?
123
+ if run.update(args.slice("name", "dataset_id", "judge_model", "output_column", "expected_column", *GENERATION_FIELDS).merge("selected_metric_ids" => resolve_metric_ids(args)))
126
124
  run.update!(tag_names: args["tag_names"]) if args.key?("tag_names")
127
125
  text_result(run_payload(run.reload))
128
126
  else
@@ -140,7 +138,7 @@ module CompletionKit
140
138
  if run.start!
141
139
  text_result(run_payload(run.reload))
142
140
  else
143
- text_result(run.failure_summary || run.errors.full_messages.to_sentence)
141
+ error_result(run.failure_summary || run.errors.full_messages.to_sentence)
144
142
  end
145
143
  end
146
144
 
@@ -149,7 +147,7 @@ module CompletionKit
149
147
  if run.regrade!
150
148
  text_result(run_payload(run.reload))
151
149
  else
152
- error_result("Nothing to re-grade. The run has no succeeded responses or no metrics attached.")
150
+ error_result(run.errors.full_messages.to_sentence.presence || "Nothing to re-grade. The run has no succeeded responses or no metrics attached.")
153
151
  end
154
152
  end
155
153
 
@@ -160,6 +158,8 @@ module CompletionKit
160
158
  else
161
159
  error_result(new_run.failure_summary || "Could not start the new run.")
162
160
  end
161
+ rescue ActiveRecord::RecordInvalid => e
162
+ error_result(e.record.errors.full_messages.join(", "))
163
163
  end
164
164
 
165
165
  def self.retry_failures(args)
@@ -67,7 +67,7 @@ module CompletionKit
67
67
 
68
68
  def extract_provider_error_message(body)
69
69
  return nil if body.blank?
70
- data = JSON.parse(body)
70
+ data = JsonObject.parse(body)
71
71
  err = data["error"]
72
72
  message = (err.is_a?(Hash) && err["message"]) || data["message"] || (err.is_a?(String) && err) || nil
73
73
  redact_secrets(message)
@@ -89,7 +89,7 @@ module CompletionKit
89
89
  req.headers["Authorization"] = "Bearer #{@api_key}"
90
90
  end
91
91
  raise_fetch_error!(response) unless response.success?
92
- JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: nil } }
92
+ JsonObject.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: nil } }
93
93
  end
94
94
 
95
95
  def fetch_anthropic_models
@@ -98,7 +98,7 @@ module CompletionKit
98
98
  req.headers["anthropic-version"] = "2023-06-01"
99
99
  end
100
100
  raise_fetch_error!(response) unless response.success?
101
- JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["display_name"] } }
101
+ JsonObject.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["display_name"] } }
102
102
  end
103
103
 
104
104
  def fetch_openrouter_models
@@ -108,7 +108,7 @@ module CompletionKit
108
108
  req.headers["X-Title"] = "CompletionKit"
109
109
  end
110
110
  raise_fetch_error!(response) unless response.success?
111
- JSON.parse(response.body).fetch("data", []).filter_map do |entry|
111
+ JsonObject.parse(response.body).fetch("data", []).filter_map do |entry|
112
112
  next nil if entry["deprecated"] == true
113
113
  context_length = entry["context_length"].to_i
114
114
  next nil if context_length < 8192
@@ -132,7 +132,7 @@ module CompletionKit
132
132
  req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
133
133
  end
134
134
  raise DiscoveryError, custom_endpoint_error_message(response) unless response.success?
135
- JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
135
+ JsonObject.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
136
136
  end
137
137
 
138
138
  def custom_endpoint_error_message(response)
@@ -181,7 +181,7 @@ module CompletionKit
181
181
  end
182
182
  raise DiscoveryError, azure_error_message(response) unless response.success?
183
183
 
184
- body = JSON.parse(response.body)
184
+ body = JsonObject.parse(response.body)
185
185
  if azure_foundry_project?
186
186
  @catalog_model_count = fetch_azure_catalog_count
187
187
  body.fetch("value", []).map { |d| { id: d["name"], display_name: d["name"] } }
@@ -196,7 +196,7 @@ module CompletionKit
196
196
  end
197
197
  return nil unless response.success?
198
198
 
199
- JSON.parse(response.body).fetch("data", []).length
199
+ JsonObject.parse(response.body).fetch("data", []).length
200
200
  rescue StandardError
201
201
  nil
202
202
  end
@@ -356,7 +356,7 @@ module CompletionKit
356
356
  end
357
357
  rescue StandardError => e
358
358
  model.supports_generation = false
359
- model.generation_error = redact_secrets(e.message)
359
+ model.generation_error = redact_secrets(EndpointError.from(e).message)
360
360
  end
361
361
 
362
362
  def probe_judging(model)
@@ -384,7 +384,7 @@ module CompletionKit
384
384
  end
385
385
  rescue StandardError => e
386
386
  model.supports_judging = false
387
- model.judging_error = redact_secrets(e.message)
387
+ model.judging_error = redact_secrets(EndpointError.from(e).message)
388
388
  end
389
389
 
390
390
  OPENAI_REASONING_PROBE_BUDGET = 65_536
@@ -405,7 +405,7 @@ module CompletionKit
405
405
  end
406
406
 
407
407
  def extract_text(response)
408
- data = JSON.parse(response.body)
408
+ data = JsonObject.parse(response.body)
409
409
  case @provider
410
410
  when "openai"
411
411
  message = Array(data["output"]).find { |o| o["type"] == "message" }
@@ -26,7 +26,7 @@ module CompletionKit
26
26
  end
27
27
 
28
28
  if response.success?
29
- data = JSON.parse(response.body)
29
+ data = JsonObject.parse(response.body)
30
30
  data["choices"][0]["text"].strip
31
31
  else
32
32
  "Error: #{response.status} - #{response.body}"
@@ -37,6 +37,8 @@ module CompletionKit
37
37
  raise
38
38
  rescue ProviderEndpoint::UnsafeEndpoint => e
39
39
  "Error: API endpoint #{e.message}"
40
+ rescue JSON::ParserError => e
41
+ "Error: #{EndpointError.from(e).message}"
40
42
  rescue => e
41
43
  "Error: #{e.message}"
42
44
  end
@@ -50,7 +52,7 @@ module CompletionKit
50
52
 
51
53
  return [] unless response.success?
52
54
 
53
- models = JSON.parse(response.body).fetch("data", []).map { |entry| entry["id"] }.sort
55
+ models = JsonObject.parse(response.body).fetch("data", []).map { |entry| entry["id"] }.sort
54
56
  models.map { |id| { id: id, name: id } }
55
57
  rescue StandardError
56
58
  []
@@ -86,7 +86,7 @@ module CompletionKit
86
86
  next
87
87
  end
88
88
 
89
- prompt = Prompt.create!(name: "Imported prompt #{index + 1}", template: template, llm_model: model)
89
+ prompt = Prompt.create!(name: "Imported prompt #{index + 1}", template: template, llm_model: model, allow_unconfigured_provider: true)
90
90
  created << prompt.name
91
91
  end
92
92
  { created: created, skipped: skipped }
@@ -5,11 +5,17 @@ module CompletionKit
5
5
  module ProviderEndpoint
6
6
  class UnsafeEndpoint < StandardError; end
7
7
 
8
- BLOCKED_NETS = [IPAddr.new("0.0.0.0/8"), IPAddr.new("100.64.0.0/10")].freeze
8
+ BLOCKED_NETS = %w[
9
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24
10
+ 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
11
+ 2001::/23 2001:db8::/32 2002::/16 3fff::/20
12
+ ].map { |net| IPAddr.new(net) }.freeze
13
+ GLOBAL_UNICAST = IPAddr.new("2000::/3")
14
+ NAT64 = IPAddr.new("64:ff9b::/96")
9
15
  ISSUE_MESSAGES = {
10
16
  invalid_url: "is not a valid http or https URL",
11
- unresolvable: "could not be resolved",
12
- unsafe_host: "resolves to a private address"
17
+ unresolvable: "does not resolve to a public address",
18
+ unsafe_host: "does not resolve to a public address"
13
19
  }.freeze
14
20
 
15
21
  module_function
@@ -66,12 +72,15 @@ module CompletionKit
66
72
 
67
73
  def unsafe?(ip)
68
74
  ip = ip.native
69
- return true if ip.private?
70
- return true if ip.link_local?
71
- return true if ip.to_i.zero?
72
- return true if ip.ipv4? && BLOCKED_NETS.any? { |net| net.include?(ip) }
73
- return true if ip.loopback? && !CompletionKit.config.allow_loopback_endpoints
74
- false
75
+ return !CompletionKit.config.allow_loopback_endpoints if ip.loopback?
76
+ return nat64_unsafe?(ip) if NAT64.include?(ip)
77
+ return true if ip.ipv6? && !GLOBAL_UNICAST.include?(ip)
78
+ BLOCKED_NETS.any? { |net| net.include?(ip) }
79
+ end
80
+
81
+ def nat64_unsafe?(ip)
82
+ target = IPAddr.new(ip.to_i & 0xffff_ffff, Socket::AF_INET)
83
+ target.loopback? || unsafe?(target)
75
84
  end
76
85
  end
77
86
  end
@@ -3,7 +3,7 @@
3
3
  <% pending_verdict = local_assigns[:pending_verdict] %>
4
4
  <% active_verdict = pending_verdict || current_verdict %>
5
5
  <% error = local_assigns[:error] %>
6
- <% me = CompletionKit.config.username.presence || "operator" %>
6
+ <% me = ck_current_reviewer %>
7
7
  <% other_agreements = CompletionKit::Agreement
8
8
  .where(response_id: response_row.id, metric_id: metric.id)
9
9
  .where.not(created_by: me)