completion-kit 0.28.43 → 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 +16 -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 +2 -2
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cd88afe200e8524d2cad09e6214612f645d9edb42ca0300c8c72c3dd2edf77c
4
- data.tar.gz: 8873756fb01f18fa8405dc486e2f8d4bfbe4a11b37c6f1288ec80012e0a6bd18
3
+ metadata.gz: 7c6a9a433f596def84c665f6b6fbe2b5aa4ed1422ac923f20a2a2d246aad02c4
4
+ data.tar.gz: 3a0ad110587d51bad3b41f89439da3efa79c199887d58b76703b0d46d16d7f6d
5
5
  SHA512:
6
- metadata.gz: 5b6f6806c9d3522221ecc2cb9249ed87082961712d1f8ed75860998e5774bc28693ea5dc0957d2a7ce371fb23f97578b87ca1b7ed181a8fe8d81e989682061c6
7
- data.tar.gz: 57c412ed0623239f5f88a731ca46a70404ad4f4211bc7ff06ab612057522b23a6edd13ac6ae9dfde578e1ba22dc5eeda0ce5ae3b9ec3488391ba844475ff860d
6
+ metadata.gz: 5f17ea2b059df5b6e2656c9a80a60d87ccd5a55b7d1d2a2599ca3b913227eca49d19a983015765b52accc0287a8e07e60dedb3e1d92d2744e81f5803a32f5b19
7
+ data.tar.gz: 673b44c3f1eb7edaf963acb85a0451067ed1f6fa9ee73fee156518c031e8fb2b74786aa119c88d39281cc36e4a5e56da3ab8354a3c0411fe1eca800ed96b275e
data/README.md CHANGED
@@ -133,6 +133,29 @@ end
133
133
 
134
134
  Only one mode can be active.
135
135
 
136
+ ### Reviewer name
137
+
138
+ When someone records a verdict on a judge's score in the web UI, CompletionKit saves it under a reviewer name. Each reviewer keeps one verdict per response and metric, so give every person their own name. Otherwise their verdicts overwrite each other. Set `current_reviewer` to a callable that receives the controller and returns the signed-in person's name:
139
+
140
+ ```ruby
141
+ CompletionKit.configure do |c|
142
+ c.auth_strategy = ->(controller) { controller.authenticate_user! }
143
+ c.current_reviewer = ->(controller) { controller.current_user&.email }
144
+ end
145
+ ```
146
+
147
+ The callable must return a String or nil. Any other value, such as a user record, raises `CompletionKit::ConfigurationError`. If `current_reviewer` is unset or returns a blank value, verdicts are saved under `username`, or `"operator"` when no username is set. The web UI does not read the `X-Remote-User` header on its own. If a proxy in front of your app sets that header, pass it through:
148
+
149
+ ```ruby
150
+ CompletionKit.configure do |c|
151
+ c.current_reviewer = ->(controller) { controller.request.headers["X-Remote-User"] }
152
+ end
153
+ ```
154
+
155
+ Only use this header when a trusted proxy always overwrites it. Otherwise any client can send it and claim any reviewer name.
156
+
157
+ The REST API and the MCP server take the reviewer name from their own `created_by` parameter.
158
+
136
159
  ## Rate limiting
137
160
 
138
161
  The REST API, the MCP endpoint, and the web UI are rate limited per IP, per minute. The defaults are generous; tune them in the initializer:
@@ -269,22 +292,24 @@ CompletionKit.configure do |config|
269
292
  end
270
293
  ```
271
294
 
272
- `tenant_scope` runs as each engine model's `default_scope` (use `unscoped` to bypass). `tenant_scope_columns` is appended to every engine uniqueness validation. Adding the tenant columns and composite unique indexes lives in your host migrations. Both defaults (`nil`, `[]`) are no-ops.
295
+ `tenant_scope` runs as each engine model's `default_scope` (use `unscoped` to bypass). `tenant_scope_columns` is appended to every engine uniqueness validation. Adding the tenant columns and composite unique indexes lives in your host migrations. Both defaults (`nil`, `[]`) are no-ops. The `completion_kit:mark_interrupted_runs_failed` rake task bypasses `tenant_scope` and marks in-flight runs in every tenant, so a host that also enforces tenancy in the database, for example with row-level security, must run it as a database role that can see every tenant's rows.
296
+
297
+ Custom model endpoints and CSV imports by URL may reach the server's own loopback interface by default, so a local Ollama works out of the box. If the people using the engine should not reach services on the app server itself, set `config.allow_loopback_endpoints = false`. Private, link-local and reserved addresses are always refused.
273
298
 
274
- One hook lets a host apply run-history retention everywhere the engine lists, counts, or traverses runs, without overriding controllers or views:
299
+ One hook lets a host apply its own display rule everywhere the engine lists, counts, or traverses runs, without overriding controllers or views:
275
300
 
276
301
  ```ruby
277
302
  CompletionKit.configure do |config|
278
303
  config.runs_display_scope = -> { where(created_at: 90.days.ago..) }
279
- config.runs_display_footer_partial = "runs/retention_notice"
304
+ config.runs_display_footer_partial = "runs/display_notice"
280
305
  end
281
306
  ```
282
307
 
283
- `runs_display_scope` is a callable evaluated against a `Run` relation, in the same bare-`where` style as `tenant_scope` (it runs via `instance_exec`, so write it zero-arg with the relation as `self`, like a Rails `scope` lambda). It must return a relation: a callable that returns `nil` or anything non-chainable raises when a list renders. The engine applies it through `Run.display_scoped` at every run list and count it owns (the runs index, prompt and dataset show pages, the compare picker, new-run tag defaults, the v1 API index and its `X-Total-Count`, the MCP `runs_list` tool, the dashboard and API reference recent-runs panels, the dashboard "Runs" stat-card count, and provider-credential usage stats) and through `Run.visible_run_ids` for child records that traverse runs (the metric trust-panel sample and the agreement examples shown on a metric page). Use it for list-only retention rather than a global `default_scope`, which would null `Run` associations everywhere they are traversed.
308
+ `runs_display_scope` is a callable evaluated against a `Run` relation, in the same bare-`where` style as `tenant_scope` (it runs via `instance_exec`, so write it zero-arg with the relation as `self`, like a Rails `scope` lambda). It must return a relation: a callable that returns `nil` or anything non-chainable raises when a list renders. The engine applies it through `Run.display_scoped` at every run list and count it owns (the runs index, prompt and dataset show pages, the compare picker, new-run tag defaults, the v1 API index and its `X-Total-Count`, the MCP `runs_list` tool, the dashboard and API reference recent-runs panels, the dashboard "Runs" stat-card count, and provider-credential usage stats) and through `Run.visible_run_ids` for child records that traverse runs (the metric trust-panel sample and the agreement examples shown on a metric page). Use it for a list-only display rule rather than a global `default_scope`, which would null `Run` associations everywhere they are traversed.
284
309
 
285
- Deliberately exempt, because they must still see every run: id-addressed single-run lookups (`runs#show`, the MCP `runs_get` tool, the v1 API show), delete-confirmation cascade counts, the auto-generated run-name counter, the dashboard's stat-card gate (it keys off the unscoped run total so a long-tenured workspace whose runs have all aged past retention still sees its activity cards rather than a blank dashboard), and the judge few-shot seeding that learns from corrected examples even on hidden runs.
310
+ Deliberately exempt, because they must still see every run: id-addressed single-run lookups (`runs#show`, the MCP `runs_get` tool, the v1 API show), delete-confirmation cascade counts, the auto-generated run-name counter, the dashboard's stat-card gate (it keys off the unscoped run total so a workspace whose runs the display scope excludes entirely still sees its activity cards rather than a blank dashboard), and the judge few-shot seeding that learns from corrected examples even on runs the display scope excludes.
286
311
 
287
- `runs_display_footer_partial` names a partial rendered below the runs list on the dashboard, the index, and the prompt and dataset show pages; it receives the shown runs as a `runs` local. Use it for a notice like "older runs are hidden, upgrade to see them" — your host owns the retention rule in `runs_display_scope`, so it computes the hidden count itself. Both default to `nil` (no-ops), leaving standalone behaviour unchanged.
312
+ `runs_display_footer_partial` names a partial rendered below the runs list on the dashboard, the index, and the prompt and dataset show pages; it receives the shown runs as a `runs` local. When the display scope excludes every run one of those surfaces would list, the surface says "No runs to show." and the footer partial renders beneath it. The engine cannot know why your scope excludes runs, so use the partial to explain your display rule: your host owns that rule in `runs_display_scope` and is the only side that can describe or count what it left out. Both default to `nil` (no-ops), leaving standalone behaviour unchanged.
288
313
 
289
314
  ## Contributing
290
315
 
@@ -286,6 +286,11 @@ form.button_to {
286
286
  margin-top: 0.45rem;
287
287
  }
288
288
 
289
+ .ck-kicker + .ck-empty,
290
+ .ck-split + .ck-empty {
291
+ margin-top: 0.5rem;
292
+ }
293
+
289
294
  .ck-pagination {
290
295
  display: flex;
291
296
  align-items: center;
@@ -5,7 +5,7 @@ module CompletionKit
5
5
  before_action :reject_check_metric, only: [:create]
6
6
 
7
7
  def create
8
- created_by = agreement_creator
8
+ created_by = ck_current_reviewer
9
9
  existing = Agreement.find_by(
10
10
  run_id: @run.id, response_id: @response.id, metric_id: @metric.id, created_by: created_by
11
11
  )
@@ -74,9 +74,5 @@ module CompletionKit
74
74
  def review_for_metric
75
75
  @response.reviews.find_by(metric_id: @metric.id)
76
76
  end
77
-
78
- def agreement_creator
79
- request.env["HTTP_X_REMOTE_USER"].presence || CompletionKit.config.username.presence || "operator"
80
- end
81
77
  end
82
78
  end
@@ -5,9 +5,14 @@ module CompletionKit
5
5
  rate_limit to: CompletionKit.config.api_rate_limit, within: 1.minute,
6
6
  with: -> { render_error("Rate limit exceeded", status: :too_many_requests) }
7
7
  before_action :authenticate_api!
8
+ rescue_from ConfigurationError, with: :render_configuration_error
8
9
 
9
10
  private
10
11
 
12
+ def render_configuration_error(error)
13
+ render_error(error.message, status: :unprocessable_entity)
14
+ end
15
+
11
16
  def render_error(message, status:, details: nil)
12
17
  payload = { error: message }
13
18
  payload[:details] = details if details.present?
@@ -15,9 +15,8 @@ module CompletionKit
15
15
  end
16
16
 
17
17
  def create
18
- metric_group = MetricGroup.new(metric_group_params.except(:metric_ids))
18
+ metric_group = MetricGroup.new(metric_group_attributes)
19
19
  if metric_group.save
20
- metric_group.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
21
20
  render json: metric_group.reload, status: :created
22
21
  else
23
22
  render_validation_errors(metric_group)
@@ -25,8 +24,7 @@ module CompletionKit
25
24
  end
26
25
 
27
26
  def update
28
- if @metric_group.update(metric_group_params.except(:metric_ids))
29
- @metric_group.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
27
+ if @metric_group.update(metric_group_attributes)
30
28
  render json: @metric_group.reload
31
29
  else
32
30
  render_validation_errors(@metric_group)
@@ -49,6 +47,10 @@ module CompletionKit
49
47
  def metric_group_params
50
48
  params.permit(:name, :description, metric_ids: [], tag_names: [])
51
49
  end
50
+
51
+ def metric_group_attributes
52
+ metric_group_params.except(:metric_ids).merge(selected_metric_ids: metric_group_params[:metric_ids])
53
+ end
52
54
  end
53
55
  end
54
56
  end
@@ -48,9 +48,9 @@ module CompletionKit
48
48
  return
49
49
  end
50
50
 
51
- MetricVersion.drafts.where(metric_id: @metric.id, source: "suggestion").destroy_all
52
51
  generator = MetricVariantGenerator.new(@metric, count: params[:count].to_i, model: params[:model])
53
52
  variants = generator.call
53
+ MetricVersion.drafts.where(metric_id: @metric.id, source: "suggestion").destroy_all
54
54
  if variants.empty?
55
55
  render_error("The model returned no usable variants. Try again with a different model.", status: :unprocessable_entity)
56
56
  return
@@ -18,9 +18,8 @@ module CompletionKit
18
18
  end
19
19
 
20
20
  def create
21
- run = Run.new(run_params.except(:metric_ids))
21
+ run = Run.new(run_attributes)
22
22
  if run.save
23
- run.replace_metrics!(params[:metric_ids])
24
23
  render json: run.reload, status: :created
25
24
  else
26
25
  render_validation_errors(run)
@@ -28,8 +27,7 @@ module CompletionKit
28
27
  end
29
28
 
30
29
  def update
31
- if @run.update(run_params.except(:metric_ids))
32
- @run.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
30
+ if @run.update(run_attributes)
33
31
  render json: @run.reload
34
32
  else
35
33
  render_validation_errors(@run)
@@ -65,13 +63,15 @@ module CompletionKit
65
63
  else
66
64
  render_error(new_run.failure_summary || "Could not start the new run.", status: :unprocessable_entity)
67
65
  end
66
+ rescue ActiveRecord::RecordInvalid => e
67
+ render_validation_errors(e.record)
68
68
  end
69
69
 
70
70
  def regrade
71
71
  if @run.regrade!
72
72
  render json: @run.reload, status: :accepted
73
73
  else
74
- render_error("Nothing to re-grade. The run has no succeeded responses or no metrics attached.", status: :unprocessable_entity)
74
+ render_error(@run.errors.full_messages.to_sentence.presence || "Nothing to re-grade. The run has no succeeded responses or no metrics attached.", status: :unprocessable_entity)
75
75
  end
76
76
  end
77
77
 
@@ -133,6 +133,10 @@ module CompletionKit
133
133
  params.permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :max_tokens, :judge_temperature, :output_column, :expected_column,
134
134
  metric_ids: [], tag_names: [])
135
135
  end
136
+
137
+ def run_attributes
138
+ run_params.except(:metric_ids).merge(selected_metric_ids: run_params[:metric_ids])
139
+ end
136
140
  end
137
141
  end
138
142
  end
@@ -8,9 +8,22 @@ module CompletionKit
8
8
  rate_limit to: CompletionKit.config.web_rate_limit, within: 1.minute,
9
9
  with: -> { render plain: "Rate limit exceeded. Please slow down.", status: :too_many_requests }
10
10
  before_action :authenticate_completion_kit!
11
+ helper_method :ck_current_reviewer
11
12
 
12
13
  private
13
14
 
15
+ def ck_current_reviewer
16
+ @ck_current_reviewer ||= begin
17
+ cfg = CompletionKit.config
18
+ name = cfg.current_reviewer&.call(self)
19
+ unless name.nil? || name.is_a?(String)
20
+ raise CompletionKit::ConfigurationError,
21
+ "current_reviewer must return a String or nil, got #{name.class}. Return a name such as current_user.email."
22
+ end
23
+ name.presence || cfg.username.presence || "operator"
24
+ end
25
+ end
26
+
14
27
  def workspace_ready?
15
28
  CompletionKit::Onboarding::Checklist.new.complete? ||
16
29
  cookies[ONBOARDING_DISMISS_COOKIE].present?
@@ -2,6 +2,8 @@ module CompletionKit
2
2
  class McpController < Api::V1::BaseController
3
3
  PUBLIC_METHODS = %w[initialize notifications/initialized tools/list].freeze
4
4
 
5
+ FOREIGN_KEY_MESSAGE = "This change references a record that does not exist or is still in use.".freeze
6
+
5
7
  skip_before_action :authenticate_api!, only: [:stream, :handle], raise: false
6
8
 
7
9
  def stream
@@ -11,6 +13,10 @@ module CompletionKit
11
13
 
12
14
  def handle
13
15
  request_body = JSON.parse(request.body.read)
16
+ unless request_body.is_a?(Hash)
17
+ render json: jsonrpc_error(nil, -32600, "Invalid Request"), status: :bad_request
18
+ return
19
+ end
14
20
  return unless authorize_mcp_method(request_body["method"])
15
21
 
16
22
  if request_body["method"] == "initialize"
@@ -35,17 +41,18 @@ module CompletionKit
35
41
  rescue McpDispatcher::InvalidParams => e
36
42
  render json: jsonrpc_error(request_body.dig("id"), -32602, e.message), status: :ok
37
43
  rescue ActiveRecord::RecordNotFound => e
44
+ render json: jsonrpc_error(request_body.dig("id"), -32602, not_found_message(e)), status: :ok
45
+ rescue ActiveRecord::RecordInvalid => e
38
46
  render json: jsonrpc_error(request_body.dig("id"), -32602, e.message), status: :ok
39
- rescue ActiveRecord::RecordInvalid, ActiveRecord::InvalidForeignKey => e
40
- render json: jsonrpc_error(request_body.dig("id"), -32602, e.message), status: :ok
47
+ rescue ActiveRecord::InvalidForeignKey => e
48
+ Rails.error.report(e, handled: true, context: { controller: "CompletionKit::McpController" })
49
+ render json: jsonrpc_error(request_body.dig("id"), -32602, FOREIGN_KEY_MESSAGE), status: :ok
41
50
  rescue StandardError => e
42
51
  Rails.error.report(e, handled: true, context: { controller: "CompletionKit::McpController" })
43
52
  render json: jsonrpc_error(request_body.dig("id"), -32603, "Internal error"), status: :ok
44
53
  end
45
54
 
46
55
  def destroy
47
- session_id = request.headers["Mcp-Session-Id"]
48
- McpSession.destroy_session(session_id) if session_id
49
56
  head :ok
50
57
  end
51
58
 
@@ -57,6 +64,11 @@ module CompletionKit
57
64
  !performed?
58
65
  end
59
66
 
67
+ def not_found_message(error)
68
+ return "Record not found" unless error.model.present? && error.id.present?
69
+ "#{error.model.demodulize.titleize} #{Array(error.id).join(", ")} not found"
70
+ end
71
+
60
72
  def jsonrpc_response(id, result)
61
73
  {jsonrpc: "2.0", id: id, result: result}
62
74
  end
@@ -20,11 +20,10 @@ module CompletionKit
20
20
  end
21
21
 
22
22
  def create
23
- @metric_group = MetricGroup.new(metric_group_params.except(:metric_ids))
23
+ @metric_group = MetricGroup.new(metric_group_attributes)
24
24
  @metrics = Metric.includes(:tags).order(:name)
25
25
 
26
26
  if @metric_group.save
27
- @metric_group.replace_metrics!(metric_group_params[:metric_ids])
28
27
  redirect_to metric_group_path(@metric_group), notice: "Metric group was successfully created."
29
28
  else
30
29
  render :new, status: :unprocessable_entity
@@ -34,8 +33,7 @@ module CompletionKit
34
33
  def update
35
34
  @metrics = Metric.includes(:tags).order(:name)
36
35
 
37
- if @metric_group.update(metric_group_params.except(:metric_ids))
38
- @metric_group.replace_metrics!(metric_group_params[:metric_ids])
36
+ if @metric_group.update(metric_group_attributes)
39
37
  redirect_to metric_group_path(@metric_group), notice: "Metric group was successfully updated."
40
38
  else
41
39
  render :edit, status: :unprocessable_entity
@@ -56,5 +54,9 @@ module CompletionKit
56
54
  def metric_group_params
57
55
  params.require(:metric_group).permit(:name, :description, metric_ids: [], tag_names: [])
58
56
  end
57
+
58
+ def metric_group_attributes
59
+ metric_group_params.except(:metric_ids).merge(selected_metric_ids: metric_group_params[:metric_ids])
60
+ end
59
61
  end
60
62
  end
@@ -29,7 +29,11 @@ module CompletionKit
29
29
  end
30
30
 
31
31
  def update
32
- if @provider_credential.update(provider_credential_params)
32
+ attributes = provider_credential_update_params
33
+ remove = attributes[:remove_api_key] == "1"
34
+ @remove_api_key = remove && attributes[:api_key].blank?
35
+
36
+ if @provider_credential.update(with_api_key_choice(attributes.except(:remove_api_key), remove: remove))
33
37
  redirect_to provider_credentials_path, notice: "Provider credential was successfully updated."
34
38
  else
35
39
  render :edit, status: :unprocessable_entity
@@ -72,5 +76,19 @@ module CompletionKit
72
76
  def provider_credential_params
73
77
  params.require(:provider_credential).permit(:provider, :api_key, :api_endpoint, :api_version)
74
78
  end
79
+
80
+ def provider_credential_update_params
81
+ params.require(:provider_credential).permit(:provider, :api_key, :api_endpoint, :api_version, :remove_api_key)
82
+ end
83
+
84
+ def with_api_key_choice(attributes, remove:)
85
+ if attributes[:api_key].present?
86
+ attributes
87
+ elsif remove
88
+ attributes.merge(api_key: nil)
89
+ else
90
+ attributes.except(:api_key)
91
+ end
92
+ end
75
93
  end
76
94
  end
@@ -40,9 +40,8 @@ module CompletionKit
40
40
  end
41
41
 
42
42
  def create
43
- @run = Run.new(run_params.except(:metric_ids))
43
+ @run = Run.new(run_attributes)
44
44
  if @run.save
45
- @run.replace_metrics!(params[:run][:metric_ids])
46
45
  redirect_to run_path(@run), notice: "Run was successfully created."
47
46
  else
48
47
  load_form_collections
@@ -52,20 +51,18 @@ module CompletionKit
52
51
 
53
52
  def update
54
53
  if @run.responses.any? && run_generation_changed?
55
- attrs = run_params.except(:metric_ids).to_h
54
+ attrs = run_attributes.to_h
56
55
  attrs.delete("name") if attrs["name"].to_s == @run.name.to_s
57
56
  new_run = Run.new(attrs.merge(status: "pending"))
58
57
  if new_run.save
59
- new_run.replace_metrics!(params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
60
58
  redirect_to run_path(new_run), notice: "Saved as a new run. The previous run and its results are preserved."
61
59
  else
62
- @run.assign_attributes(run_params.except(:metric_ids))
60
+ @run.assign_attributes(run_attributes)
63
61
  new_run.errors.each { |error| @run.errors.add(error.attribute, error.message) }
64
62
  load_form_collections
65
63
  render :edit, status: :unprocessable_entity
66
64
  end
67
- elsif @run.update(run_params.except(:metric_ids))
68
- @run.replace_metrics!(params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
65
+ elsif @run.update(run_attributes)
69
66
  redirect_to run_path(@run), notice: "Run saved."
70
67
  else
71
68
  load_form_collections
@@ -106,7 +103,7 @@ module CompletionKit
106
103
  if @run.regrade!
107
104
  redirect_to run_path(@run), notice: "Re-grading existing responses against the current metrics."
108
105
  else
109
- redirect_to run_path(@run), alert: "Nothing to re-grade. The run has no succeeded responses or no metrics attached."
106
+ redirect_to run_path(@run), alert: @run.errors.full_messages.to_sentence.presence || "Nothing to re-grade. The run has no succeeded responses or no metrics attached."
110
107
  end
111
108
  end
112
109
 
@@ -117,6 +114,10 @@ module CompletionKit
117
114
  else
118
115
  redirect_to run_path(new_run), alert: new_run.failure_summary || "Could not start the new run."
119
116
  end
117
+ rescue ActiveRecord::RecordInvalid => e
118
+ redirect_to run_path(@run), alert: "This run can't be rerun. #{e.record.errors.full_messages.to_sentence}."
119
+ rescue ConfigurationError => e
120
+ redirect_to run_path(@run), alert: e.message
120
121
  end
121
122
 
122
123
  # The web process drives the live run page, because worker-side Turbo
@@ -226,6 +227,10 @@ module CompletionKit
226
227
  params.require(:run).permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, :max_tokens, :judge_temperature, :output_column, :expected_column, metric_ids: [], tag_names: [])
227
228
  end
228
229
 
230
+ def run_attributes
231
+ run_params.except(:metric_ids).merge(selected_metric_ids: run_params[:metric_ids])
232
+ end
233
+
229
234
  # Editing a run that already has results forks a new run — but only when a
230
235
  # field that affects generation or judging changed. Renaming or retagging is
231
236
  # pure metadata and updates the run in place.
@@ -76,7 +76,7 @@ module CompletionKit
76
76
  error_provider: nil, error_class: nil, error_status: nil, error_message: nil
77
77
  )
78
78
 
79
- if run.llm_judge_configured?
79
+ if run.judge_model.present?
80
80
  run.llm_metrics.each do |metric|
81
81
  JudgeReviewJob.perform_later(response.id, metric.id, run.id)
82
82
  end
@@ -101,6 +101,7 @@ module CompletionKit
101
101
  response = Response.find_by(id: @response_id)
102
102
  return unless response
103
103
 
104
+ error = EndpointError.from(error)
104
105
  response.update!(
105
106
  status: "failed",
106
107
  error_provider: provider_for(response),
@@ -117,6 +117,7 @@ module CompletionKit
117
117
  response = Response.find_by(id: @response_id)
118
118
  return unless response
119
119
 
120
+ error = EndpointError.from(error)
120
121
  review = response.reviews.find_or_initialize_by(metric_id: @metric_id)
121
122
  review.assign_attributes(
122
123
  metric_name: review.metric_name || Metric.find_by(id: @metric_id)&.name || "(deleted metric)",
@@ -17,10 +17,9 @@ module CompletionKit
17
17
  return unless @metric
18
18
  return if @metric.check?
19
19
 
20
- MetricVersion.drafts.where(metric_id: @metric.id, source: "suggestion").destroy_all
21
-
22
20
  generator = MetricVariantGenerator.new(@metric, count: 1)
23
21
  variants = generator.call
22
+ MetricVersion.drafts.where(metric_id: @metric.id, source: "suggestion").destroy_all
24
23
  if variants.empty?
25
24
  broadcast_status(@metric, partial: "completion_kit/metrics/suggestion_failed", locals: { metric: @metric })
26
25
  return
@@ -8,26 +8,23 @@ module CompletionKit
8
8
  30 * executions
9
9
  end
10
10
 
11
+ rescue_from(StandardError) do |error|
12
+ report_and_record_failure!(error)
13
+ end
14
+
11
15
  retry_on Faraday::TimeoutError,
12
16
  Faraday::ConnectionFailed,
13
- wait: :polynomially_longer, attempts: 5
17
+ wait: :polynomially_longer, attempts: 5 do |job, error|
18
+ job.send(:report_and_record_failure!, error)
19
+ end
14
20
 
15
21
  retry_on CompletionKit::RateLimitError,
16
- wait: method(:rate_limit_wait), attempts: 5
22
+ wait: ->(executions) { rate_limit_wait(executions) }, attempts: 5 do |job, error|
23
+ job.send(:report_and_record_failure!, error)
24
+ end
17
25
 
18
26
  discard_on ActiveJob::DeserializationError
19
27
 
20
- rescue_from(StandardError) do |error|
21
- unless error.is_a?(CompletionKit::ModelDiscoveryService::DiscoveryError)
22
- Rails.error.report(error, handled: true, context: { job: self.class.name, provider_credential_id: arguments.first })
23
- end
24
- credential = ProviderCredential.find_by(id: arguments.first)
25
- next unless credential
26
- credential.update_columns(discovery_status: "failed", discovery_error: error.message.to_s.truncate(500))
27
- credential.reload
28
- credential.broadcast_discovery_progress
29
- end
30
-
31
28
  def perform(provider_credential_id, force: false)
32
29
  credential = ProviderCredential.find_by(id: provider_credential_id)
33
30
  return unless credential
@@ -57,5 +54,23 @@ module CompletionKit
57
54
  credential.reload
58
55
  credential.broadcast_discovery_complete
59
56
  end
57
+
58
+ private
59
+
60
+ def report_and_record_failure!(error)
61
+ unless error.is_a?(CompletionKit::ModelDiscoveryService::DiscoveryError)
62
+ Rails.error.report(error, handled: true, context: { job: self.class.name, provider_credential_id: arguments.first })
63
+ end
64
+ record_failure!(error)
65
+ end
66
+
67
+ def record_failure!(error)
68
+ credential = ProviderCredential.find_by(id: arguments.first)
69
+ return unless credential
70
+
71
+ credential.update_columns(discovery_status: "failed", discovery_error: EndpointError.from(error).message.to_s.truncate(500))
72
+ credential.reload
73
+ credential.broadcast_discovery_progress
74
+ end
60
75
  end
61
76
  end
@@ -1,6 +1,7 @@
1
1
  module CompletionKit
2
2
  class MetricGroup < ApplicationRecord
3
3
  include CompletionKit::Taggable
4
+ include CompletionKit::MetricSelection
4
5
 
5
6
  self.table_name = "completion_kit_metric_groups"
6
7
 
@@ -14,9 +15,10 @@ module CompletionKit
14
15
  end
15
16
 
16
17
  def replace_metrics!(metric_ids)
17
- return unless metric_ids
18
+ return if metric_ids.nil?
19
+
18
20
  metric_group_memberships.delete_all
19
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
21
+ normalize_metric_ids(metric_ids).each_with_index do |metric_id, index|
20
22
  metric_group_memberships.create!(metric_id: metric_id, position: index + 1)
21
23
  end
22
24
  end
@@ -3,7 +3,7 @@ module CompletionKit
3
3
  self.table_name = "completion_kit_metric_group_memberships"
4
4
 
5
5
  belongs_to :metric_group, class_name: "CompletionKit::MetricGroup", foreign_key: "metric_group_id"
6
- belongs_to :metric
6
+ belongs_to :metric, optional: false
7
7
 
8
8
  validates :metric_id, uniqueness: { scope: :metric_group_id }
9
9
 
@@ -5,7 +5,7 @@ module CompletionKit
5
5
  has_many :runs, dependent: :destroy
6
6
  has_many :responses, through: :runs
7
7
 
8
- attr_accessor :cloned_from_llm_model
8
+ attr_accessor :cloned_from_llm_model, :allow_unconfigured_provider
9
9
 
10
10
  validates :name, presence: true
11
11
  validates :template, presence: true
@@ -105,12 +105,13 @@ module CompletionKit
105
105
  end
106
106
 
107
107
  def llm_model_usable_for_generation
108
- rows = Model.where(model_id: llm_model)
109
- return if rows.empty?
110
- return if rows.where(supports_generation: true, status: "active").exists?
111
- return unless rows.where(supports_generation: false).exists?
108
+ return if Model.for_generation.exists?(model_id: llm_model)
112
109
 
113
- errors.add(:llm_model, "is not available for generating responses")
110
+ if Model.exists?(model_id: llm_model, supports_generation: false)
111
+ errors.add(:llm_model, "is not available for generating responses")
112
+ elsif !allow_unconfigured_provider && !ApiConfig.servable_model?(llm_model)
113
+ errors.add(:llm_model, %("#{llm_model}" is not served by any configured provider))
114
+ end
114
115
  end
115
116
 
116
117
  def assign_family_key
@@ -27,6 +27,7 @@ module CompletionKit
27
27
  validates :provider, tenant_scoped_uniqueness: true
28
28
  validates :api_endpoint, presence: true, if: :azure_foundry?
29
29
  validate :api_endpoint_not_internal
30
+ validate :api_key_reentered_for_new_destination, on: :update
30
31
 
31
32
  after_save :enqueue_discovery
32
33
  before_destroy :ensure_not_in_use, prepend: true
@@ -36,6 +37,15 @@ module CompletionKit
36
37
  provider == "azure_foundry"
37
38
  end
38
39
 
40
+ def api_key_stored?
41
+ api_key_in_database.present?
42
+ end
43
+
44
+ def api_key_ending
45
+ key = api_key_in_database.to_s
46
+ key.last(4) if key.length > 8
47
+ end
48
+
39
49
  def config_hash
40
50
  {
41
51
  provider: provider,
@@ -180,14 +190,21 @@ module CompletionKit
180
190
  Rails.logger.error("[CompletionKit] discovery broadcast render failed: #{e.class}: #{e.message}")
181
191
  end
182
192
 
193
+ def api_key_reentered_for_new_destination
194
+ return if api_key_came_from_user? || api_key.blank?
195
+ return unless provider_changed? || api_endpoint_in_database.presence != api_endpoint.presence
196
+
197
+ errors.add(:api_key, "must be entered again when the provider or endpoint changes")
198
+ end
199
+
183
200
  def api_endpoint_not_internal
184
201
  return if api_endpoint.blank?
185
202
 
186
203
  issues = ProviderEndpoint.validate(api_endpoint)
187
204
  if issues.include?(:invalid_url)
188
205
  errors.add(:api_endpoint, "must be a valid http or https URL")
189
- elsif issues.include?(:unsafe_host)
190
- errors.add(:api_endpoint, "must not point at a private or internal address")
206
+ elsif issues.any?
207
+ errors.add(:api_endpoint, "must resolve to a public address")
191
208
  end
192
209
  end
193
210
  end