completion-kit 0.1.0 → 0.2.0

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -0
  3. data/app/controllers/completion_kit/api/v1/metric_groups_controller.rb +2 -11
  4. data/app/controllers/completion_kit/api/v1/runs_controller.rb +2 -10
  5. data/app/controllers/completion_kit/metric_groups_controller.rb +2 -10
  6. data/app/controllers/completion_kit/runs_controller.rb +3 -10
  7. data/app/helpers/completion_kit/application_helper.rb +1 -8
  8. data/app/models/completion_kit/application_record.rb +7 -0
  9. data/app/models/completion_kit/metric.rb +1 -1
  10. data/app/models/completion_kit/metric_group.rb +8 -0
  11. data/app/models/completion_kit/model.rb +1 -1
  12. data/app/models/completion_kit/provider_credential.rb +2 -1
  13. data/app/models/completion_kit/run.rb +11 -3
  14. data/app/services/completion_kit/anthropic_client.rb +4 -17
  15. data/app/services/completion_kit/llm_client.rb +15 -0
  16. data/app/services/completion_kit/mcp_tools/base.rb +23 -0
  17. data/app/services/completion_kit/mcp_tools/datasets.rb +2 -18
  18. data/app/services/completion_kit/mcp_tools/metric_groups.rb +4 -28
  19. data/app/services/completion_kit/mcp_tools/metrics.rb +2 -18
  20. data/app/services/completion_kit/mcp_tools/prompts.rb +2 -18
  21. data/app/services/completion_kit/mcp_tools/provider_credentials.rb +2 -18
  22. data/app/services/completion_kit/mcp_tools/responses.rb +2 -13
  23. data/app/services/completion_kit/mcp_tools/runs.rb +4 -28
  24. data/app/services/completion_kit/ollama_client.rb +2 -15
  25. data/app/services/completion_kit/open_ai_client.rb +1 -10
  26. data/app/services/completion_kit/open_router_client.rb +1 -12
  27. data/app/validators/completion_kit/tenant_scoped_uniqueness_validator.rb +15 -0
  28. data/lib/completion_kit/version.rb +1 -1
  29. data/lib/completion_kit.rb +5 -0
  30. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8b122b978bb3d74051e14d734203da11cd951ef6ea0cb60b0459215812000e9
4
- data.tar.gz: 458b93ab81bf13dcaf1fd7431e6934899855b4e6513ef16e82b4cd085dcb3e67
3
+ metadata.gz: 621db92d1653ef6d326f46a164593ea731817000771aeaa649263fcf5d0a35e4
4
+ data.tar.gz: 7fbee94e29658df3508530710600a862e18b84d3b0c7b7b0f9d6ee96de1a6bbd
5
5
  SHA512:
6
- metadata.gz: d3c49afda3bb03eda67c89df4b5a8144da2259e8002ff35e906fa7516e0d115c0c7e3549a6b2de78f5478b72fefdcf758a0f6e042f83d3befc430c0bb26e7fa0
7
- data.tar.gz: ed5fb37dcacd8c3bc1947d75b7ff83e4abde0fbcc418944ac7f3f4dc837679eb6d5cce5c144546ddd59aa4bc6548bdb83ac03c4b13428b916ef6e40b0f510e10
6
+ metadata.gz: c76b3407321ed225a97516ace2a14f39786514c2fcc393e105c2be4c04f572a01395058d9af0854164135321b2cea3b711d9675431c9d62e19fe6ca235c514df
7
+ data.tar.gz: a175cf106b5ccfb2d6605c70be85c3fe96a52962187a3a50de6bfe75322c06a4aa818b8a17ac65208479acb6eba51b1de5fe9004505ed2068881faebfb7c1334
data/README.md CHANGED
@@ -178,6 +178,22 @@ bin/rails db:migrate
178
178
  git add db/migrate/ && git commit -m "install new engine migration"
179
179
  ```
180
180
 
181
+ ## Multi-tenant host apps (advanced)
182
+
183
+ For hosts that mount CompletionKit in a multi-tenant app, two optional hooks scope engine records per tenant without forking the engine:
184
+
185
+ ```ruby
186
+ CompletionKit.configure do |config|
187
+ config.tenant_scope = -> {
188
+ org = Current.organization&.id
189
+ org ? where(organization_id: org) : where("1=0")
190
+ }
191
+ config.tenant_scope_columns = [:organization_id]
192
+ end
193
+ ```
194
+
195
+ `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.
196
+
181
197
  ## Contributing
182
198
 
183
199
  See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and pull request guidelines.
@@ -15,7 +15,7 @@ module CompletionKit
15
15
  def create
16
16
  metric_group = MetricGroup.new(metric_group_params.except(:metric_ids))
17
17
  if metric_group.save
18
- replace_metric_memberships(metric_group, params[:metric_ids]) if params.key?(:metric_ids)
18
+ metric_group.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
19
19
  render json: metric_group.reload, status: :created
20
20
  else
21
21
  render json: {errors: metric_group.errors}, status: :unprocessable_entity
@@ -24,7 +24,7 @@ module CompletionKit
24
24
 
25
25
  def update
26
26
  if @metric_group.update(metric_group_params.except(:metric_ids))
27
- replace_metric_memberships(@metric_group, params[:metric_ids]) if params.key?(:metric_ids)
27
+ @metric_group.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
28
28
  render json: @metric_group.reload
29
29
  else
30
30
  render json: {errors: @metric_group.errors}, status: :unprocessable_entity
@@ -47,15 +47,6 @@ module CompletionKit
47
47
  def metric_group_params
48
48
  params.permit(:name, :description, metric_ids: [])
49
49
  end
50
-
51
- def replace_metric_memberships(metric_group, metric_ids)
52
- return unless metric_ids
53
-
54
- metric_group.metric_group_memberships.delete_all
55
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
56
- metric_group.metric_group_memberships.create!(metric_id: metric_id, position: index + 1)
57
- end
58
- end
59
50
  end
60
51
  end
61
52
  end
@@ -15,7 +15,7 @@ module CompletionKit
15
15
  def create
16
16
  run = Run.new(run_params.except(:metric_ids))
17
17
  if run.save
18
- replace_run_metrics(run, params[:metric_ids])
18
+ run.replace_metrics!(params[:metric_ids])
19
19
  render json: run.reload, status: :created
20
20
  else
21
21
  render json: {errors: run.errors}, status: :unprocessable_entity
@@ -24,7 +24,7 @@ module CompletionKit
24
24
 
25
25
  def update
26
26
  if @run.update(run_params.except(:metric_ids))
27
- replace_run_metrics(@run, params[:metric_ids]) if params.key?(:metric_ids)
27
+ @run.replace_metrics!(params[:metric_ids]) if params.key?(:metric_ids)
28
28
  render json: @run.reload
29
29
  else
30
30
  render json: {errors: @run.errors}, status: :unprocessable_entity
@@ -57,14 +57,6 @@ module CompletionKit
57
57
  def run_params
58
58
  params.permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, metric_ids: [])
59
59
  end
60
-
61
- def replace_run_metrics(run, metric_ids)
62
- return unless metric_ids
63
- run.run_metrics.delete_all
64
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
65
- run.run_metrics.create!(metric_id: metric_id, position: index + 1)
66
- end
67
- end
68
60
  end
69
61
  end
70
62
  end
@@ -23,7 +23,7 @@ module CompletionKit
23
23
  @metrics = Metric.order(:name)
24
24
 
25
25
  if @metric_group.save
26
- replace_metric_memberships
26
+ @metric_group.replace_metrics!(metric_group_params[:metric_ids])
27
27
  redirect_to metric_group_path(@metric_group), notice: "Metric group was successfully created."
28
28
  else
29
29
  render :new, status: :unprocessable_entity
@@ -34,7 +34,7 @@ module CompletionKit
34
34
  @metrics = Metric.order(:name)
35
35
 
36
36
  if @metric_group.update(metric_group_params.except(:metric_ids))
37
- replace_metric_memberships
37
+ @metric_group.replace_metrics!(metric_group_params[:metric_ids])
38
38
  redirect_to metric_group_path(@metric_group), notice: "Metric group was successfully updated."
39
39
  else
40
40
  render :edit, status: :unprocessable_entity
@@ -55,13 +55,5 @@ module CompletionKit
55
55
  def metric_group_params
56
56
  params.require(:metric_group).permit(:name, :description, metric_ids: [])
57
57
  end
58
-
59
- def replace_metric_memberships
60
- metric_ids = Array(metric_group_params[:metric_ids]).reject(&:blank?)
61
- @metric_group.metric_group_memberships.delete_all
62
- metric_ids.each_with_index do |metric_id, index|
63
- @metric_group.metric_group_memberships.create!(metric_id: metric_id, position: index + 1)
64
- end
65
- end
66
58
  end
67
59
  end
@@ -35,7 +35,7 @@ module CompletionKit
35
35
  def create
36
36
  @run = Run.new(run_params.except(:metric_ids))
37
37
  if @run.save
38
- replace_run_metrics(@run, params[:run][:metric_ids])
38
+ @run.replace_metrics!(params[:run][:metric_ids])
39
39
  redirect_to run_path(@run), notice: "Run was successfully created."
40
40
  else
41
41
  load_form_collections
@@ -46,10 +46,10 @@ module CompletionKit
46
46
  def update
47
47
  if @run.responses.any?
48
48
  new_run = Run.create!(run_params.except(:metric_ids).to_h.merge(status: "pending"))
49
- replace_run_metrics(new_run, params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
49
+ new_run.replace_metrics!(params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
50
50
  redirect_to run_path(new_run), notice: "Saved as a new run. The previous run and its results are preserved."
51
51
  elsif @run.update(run_params.except(:metric_ids))
52
- replace_run_metrics(@run, params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
52
+ @run.replace_metrics!(params[:run][:metric_ids]) if params[:run].key?(:metric_ids)
53
53
  redirect_to run_path(@run), notice: "Run saved."
54
54
  else
55
55
  load_form_collections
@@ -120,12 +120,5 @@ module CompletionKit
120
120
  params.require(:run).permit(:name, :prompt_id, :dataset_id, :judge_model, :temperature, metric_ids: [])
121
121
  end
122
122
 
123
- def replace_run_metrics(run, metric_ids)
124
- return unless metric_ids
125
- run.run_metrics.delete_all
126
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
127
- run.run_metrics.create!(metric_id: metric_id, position: index + 1)
128
- end
129
- end
130
123
  end
131
124
  end
@@ -76,15 +76,8 @@ module CompletionKit
76
76
  end
77
77
  end
78
78
 
79
- PROVIDER_LABELS = {
80
- "openai" => "OpenAI",
81
- "anthropic" => "Anthropic",
82
- "ollama" => "Ollama / local endpoint",
83
- "openrouter" => "OpenRouter"
84
- }.freeze
85
-
86
79
  def ck_provider_label(provider)
87
- PROVIDER_LABELS[provider.to_s] || provider.to_s.titleize
80
+ CompletionKit::ProviderCredential::PROVIDER_LABELS[provider.to_s] || provider.to_s.titleize
88
81
  end
89
82
 
90
83
  def ck_grouped_models(models, selected = nil)
@@ -1,5 +1,12 @@
1
1
  module CompletionKit
2
2
  class ApplicationRecord < ActiveRecord::Base
3
3
  self.abstract_class = true
4
+
5
+ TenantScopedUniquenessValidator = CompletionKit::TenantScopedUniquenessValidator
6
+
7
+ default_scope do
8
+ scope_proc = CompletionKit.config.tenant_scope
9
+ scope_proc ? instance_exec(&scope_proc) : all
10
+ end
4
11
  end
5
12
  end
@@ -15,7 +15,7 @@ module CompletionKit
15
15
  serialize :rubric_bands, coder: JSON
16
16
 
17
17
  validates :name, presence: true
18
- validates :key, uniqueness: true, allow_nil: true
18
+ validates :key, tenant_scoped_uniqueness: { allow_nil: true }
19
19
 
20
20
  before_validation :generate_key
21
21
  before_validation :normalize_rubric_bands
@@ -11,6 +11,14 @@ module CompletionKit
11
11
  metric_group_memberships.includes(:metric).map(&:metric).compact
12
12
  end
13
13
 
14
+ def replace_metrics!(metric_ids)
15
+ return unless metric_ids
16
+ metric_group_memberships.delete_all
17
+ Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
18
+ metric_group_memberships.create!(metric_id: metric_id, position: index + 1)
19
+ end
20
+ end
21
+
14
22
  def as_json(options = {})
15
23
  {
16
24
  id: id, name: name, description: description,
@@ -3,7 +3,7 @@ module CompletionKit
3
3
  STATUSES = %w[active retired failed].freeze
4
4
 
5
5
  validates :provider, presence: true
6
- validates :model_id, presence: true, uniqueness: { scope: :provider }
6
+ validates :model_id, presence: true, tenant_scoped_uniqueness: { scope: :provider }
7
7
  validates :status, presence: true, inclusion: { in: STATUSES }
8
8
 
9
9
  scope :active, -> { where(status: "active") }
@@ -22,7 +22,8 @@ module CompletionKit
22
22
  PROVIDER_LABELS[provider] || provider.titleize
23
23
  end
24
24
 
25
- validates :provider, presence: true, inclusion: { in: PROVIDERS }, uniqueness: true
25
+ validates :provider, presence: true, inclusion: { in: PROVIDERS }
26
+ validates :provider, tenant_scoped_uniqueness: true
26
27
 
27
28
  after_save :enqueue_discovery
28
29
 
@@ -21,6 +21,14 @@ module CompletionKit
21
21
  judge_model.present? && metrics.any? && ApiConfig.valid_for_model?(judge_model)
22
22
  end
23
23
 
24
+ def replace_metrics!(metric_ids)
25
+ return unless metric_ids
26
+ run_metrics.delete_all
27
+ Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
28
+ run_metrics.create!(metric_id: metric_id, position: index + 1)
29
+ end
30
+ end
31
+
24
32
  def avg_score
25
33
  all_reviews = responses.flat_map(&:reviews)
26
34
  scores = all_reviews.map(&:ai_score).compact.map(&:to_f)
@@ -113,15 +121,15 @@ module CompletionKit
113
121
  response.response_text,
114
122
  response.expected_output,
115
123
  prompt.template,
116
- criteria: metric.respond_to?(:instruction) ? metric.instruction.to_s : "",
117
- rubric_text: metric.respond_to?(:display_rubric_text) ? metric.display_rubric_text : nil,
124
+ criteria: metric.instruction.to_s,
125
+ rubric_text: metric.display_rubric_text,
118
126
  input_data: response.input_data
119
127
  )
120
128
 
121
129
  response.reviews.find_or_initialize_by(metric_id: metric.id).tap do |review|
122
130
  review.assign_attributes(
123
131
  metric_name: metric.name,
124
- instruction: metric.respond_to?(:instruction) ? metric.instruction.to_s : "",
132
+ instruction: metric.instruction.to_s,
125
133
  status: "evaluated",
126
134
  ai_score: evaluation[:score],
127
135
  ai_feedback: evaluation[:feedback]
@@ -7,21 +7,12 @@ module CompletionKit
7
7
 
8
8
  def generate_completion(prompt, options = {})
9
9
  return "Error: API key not configured" unless configured?
10
-
11
- require "faraday"
12
- require "faraday/retry"
13
- require "json"
14
-
10
+
15
11
  model = options[:model] || "claude-3-7-sonnet-latest"
16
12
  max_tokens = options[:max_tokens] || 1000
17
13
  temperature = options[:temperature] || 0.7
18
-
19
- conn = Faraday.new(url: "https://api.anthropic.com") do |f|
20
- f.request :retry, max: 2, interval: 0.5
21
- f.adapter Faraday.default_adapter
22
- end
23
-
24
- response = conn.post do |req|
14
+
15
+ response = build_connection("https://api.anthropic.com").post do |req|
25
16
  req.url "/v1/messages"
26
17
  req.headers["Content-Type"] = "application/json"
27
18
  req.headers["x-api-key"] = api_key
@@ -49,11 +40,7 @@ module CompletionKit
49
40
  def available_models
50
41
  return STATIC_MODELS unless configured?
51
42
 
52
- require "faraday"
53
- require "faraday/retry"
54
- require "json"
55
-
56
- response = Faraday.get("https://api.anthropic.com/v1/models?limit=100") do |req|
43
+ response = build_connection("https://api.anthropic.com").get("/v1/models?limit=100") do |req|
57
44
  req.headers["x-api-key"] = api_key
58
45
  req.headers["anthropic-version"] = "2023-06-01"
59
46
  end
@@ -1,3 +1,7 @@
1
+ require "faraday"
2
+ require "faraday/retry"
3
+ require "json"
4
+
1
5
  module CompletionKit
2
6
  class LlmClient
3
7
  def initialize(config = {})
@@ -41,5 +45,16 @@ module CompletionKit
41
45
 
42
46
  for_provider(provider, config)
43
47
  end
48
+
49
+ protected
50
+
51
+ def build_connection(url, timeout: nil, open_timeout: nil)
52
+ Faraday.new(url: url) do |f|
53
+ f.options.timeout = timeout if timeout
54
+ f.options.open_timeout = open_timeout if open_timeout
55
+ f.request :retry, max: 2, interval: 0.5
56
+ f.adapter Faraday.default_adapter
57
+ end
58
+ end
44
59
  end
45
60
  end
@@ -0,0 +1,23 @@
1
+ module CompletionKit
2
+ module McpTools
3
+ module Base
4
+ def definitions
5
+ self::TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
6
+ end
7
+
8
+ def call(name, arguments)
9
+ tool = self::TOOLS.fetch(name)
10
+ send(tool[:handler], arguments)
11
+ end
12
+
13
+ def text_result(data)
14
+ text = data.is_a?(String) ? data : data.to_json
15
+ {content: [{type: "text", text: text}]}
16
+ end
17
+
18
+ def error_result(message)
19
+ {content: [{type: "text", text: message}], isError: true}
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module Datasets
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "datasets_list" => {
6
8
  description: "List all datasets",
@@ -37,15 +39,6 @@ module CompletionKit
37
39
  }
38
40
  }.freeze
39
41
 
40
- def self.definitions
41
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
42
- end
43
-
44
- def self.call(name, arguments)
45
- tool = TOOLS.fetch(name)
46
- send(tool[:handler], arguments)
47
- end
48
-
49
42
  def self.list(_args)
50
43
  text_result(Dataset.order(created_at: :desc).map(&:as_json))
51
44
  end
@@ -76,15 +69,6 @@ module CompletionKit
76
69
  Dataset.find(args["id"]).destroy!
77
70
  text_result("Dataset #{args["id"]} deleted")
78
71
  end
79
-
80
- def self.text_result(data)
81
- text = data.is_a?(String) ? data : data.to_json
82
- {content: [{type: "text", text: text}]}
83
- end
84
-
85
- def self.error_result(message)
86
- {content: [{type: "text", text: message}], isError: true}
87
- end
88
72
  end
89
73
  end
90
74
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module MetricGroups
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "metric_groups_list" => {
6
8
  description: "List all metric groups",
@@ -43,15 +45,6 @@ module CompletionKit
43
45
  }
44
46
  }.freeze
45
47
 
46
- def self.definitions
47
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
48
- end
49
-
50
- def self.call(name, arguments)
51
- tool = TOOLS.fetch(name)
52
- send(tool[:handler], arguments)
53
- end
54
-
55
48
  def self.list(_args)
56
49
  text_result(CompletionKit::MetricGroup.order(created_at: :desc).map(&:as_json))
57
50
  end
@@ -63,7 +56,7 @@ module CompletionKit
63
56
  def self.create(args)
64
57
  metric_group = CompletionKit::MetricGroup.new(args.slice("name", "description"))
65
58
  if metric_group.save
66
- replace_metric_memberships(metric_group, args["metric_ids"])
59
+ metric_group.replace_metrics!(args["metric_ids"])
67
60
  text_result(metric_group.reload.as_json)
68
61
  else
69
62
  error_result(metric_group.errors.full_messages.join(", "))
@@ -73,7 +66,7 @@ module CompletionKit
73
66
  def self.update(args)
74
67
  metric_group = CompletionKit::MetricGroup.find(args["id"])
75
68
  if metric_group.update(args.except("id", "metric_ids").slice("name", "description"))
76
- replace_metric_memberships(metric_group, args["metric_ids"]) if args.key?("metric_ids")
69
+ metric_group.replace_metrics!(args["metric_ids"]) if args.key?("metric_ids")
77
70
  text_result(metric_group.reload.as_json)
78
71
  else
79
72
  error_result(metric_group.errors.full_messages.join(", "))
@@ -84,23 +77,6 @@ module CompletionKit
84
77
  CompletionKit::MetricGroup.find(args["id"]).destroy!
85
78
  text_result("Metric group #{args["id"]} deleted")
86
79
  end
87
-
88
- def self.text_result(data)
89
- text = data.is_a?(String) ? data : data.to_json
90
- {content: [{type: "text", text: text}]}
91
- end
92
-
93
- def self.error_result(message)
94
- {content: [{type: "text", text: message}], isError: true}
95
- end
96
-
97
- def self.replace_metric_memberships(metric_group, metric_ids)
98
- return unless metric_ids
99
- metric_group.metric_group_memberships.delete_all
100
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
101
- metric_group.metric_group_memberships.create!(metric_id: metric_id, position: index + 1)
102
- end
103
- end
104
80
  end
105
81
  end
106
82
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module Metrics
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "metrics_list" => {
6
8
  description: "List all metrics",
@@ -43,15 +45,6 @@ module CompletionKit
43
45
  }
44
46
  }.freeze
45
47
 
46
- def self.definitions
47
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
48
- end
49
-
50
- def self.call(name, arguments)
51
- tool = TOOLS.fetch(name)
52
- send(tool[:handler], arguments)
53
- end
54
-
55
48
  def self.list(_args)
56
49
  text_result(Metric.order(created_at: :desc).map(&:as_json))
57
50
  end
@@ -82,15 +75,6 @@ module CompletionKit
82
75
  Metric.find(args["id"]).destroy!
83
76
  text_result("Metric #{args["id"]} deleted")
84
77
  end
85
-
86
- def self.text_result(data)
87
- text = data.is_a?(String) ? data : data.to_json
88
- {content: [{type: "text", text: text}]}
89
- end
90
-
91
- def self.error_result(message)
92
- {content: [{type: "text", text: message}], isError: true}
93
- end
94
78
  end
95
79
  end
96
80
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module Prompts
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "prompts_list" => {
6
8
  description: "List all prompts",
@@ -48,15 +50,6 @@ module CompletionKit
48
50
  },
49
51
  }.freeze
50
52
 
51
- def self.definitions
52
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
53
- end
54
-
55
- def self.call(name, arguments)
56
- tool = TOOLS.fetch(name)
57
- send(tool[:handler], arguments)
58
- end
59
-
60
53
  def self.list(_args)
61
54
  text_result(Prompt.order(created_at: :desc).map(&:as_json))
62
55
  end
@@ -98,15 +91,6 @@ module CompletionKit
98
91
  prompt.publish!
99
92
  text_result(prompt.reload.as_json)
100
93
  end
101
-
102
- def self.text_result(data)
103
- text = data.is_a?(String) ? data : data.to_json
104
- {content: [{type: "text", text: text}]}
105
- end
106
-
107
- def self.error_result(message)
108
- {content: [{type: "text", text: message}], isError: true}
109
- end
110
94
  end
111
95
  end
112
96
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module ProviderCredentials
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "provider_credentials_list" => {
6
8
  description: "List all provider credentials (API keys are not exposed)",
@@ -44,15 +46,6 @@ module CompletionKit
44
46
  }
45
47
  }.freeze
46
48
 
47
- def self.definitions
48
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
49
- end
50
-
51
- def self.call(name, arguments)
52
- tool = TOOLS.fetch(name)
53
- send(tool[:handler], arguments)
54
- end
55
-
56
49
  def self.list(_args)
57
50
  text_result(ProviderCredential.order(created_at: :desc).map(&:as_json))
58
51
  end
@@ -83,15 +76,6 @@ module CompletionKit
83
76
  ProviderCredential.find(args["id"]).destroy!
84
77
  text_result("Provider credential #{args["id"]} deleted")
85
78
  end
86
-
87
- def self.text_result(data)
88
- text = data.is_a?(String) ? data : data.to_json
89
- {content: [{type: "text", text: text}]}
90
- end
91
-
92
- def self.error_result(message)
93
- {content: [{type: "text", text: message}], isError: true}
94
- end
95
79
  end
96
80
  end
97
81
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module Responses
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "responses_list" => {
6
8
  description: "List responses for a run",
@@ -18,15 +20,6 @@ module CompletionKit
18
20
  }
19
21
  }.freeze
20
22
 
21
- def self.definitions
22
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
23
- end
24
-
25
- def self.call(name, arguments)
26
- tool = TOOLS.fetch(name)
27
- send(tool[:handler], arguments)
28
- end
29
-
30
23
  def self.list(args)
31
24
  run = Run.find(args["run_id"])
32
25
  text_result(run.responses.includes(:reviews).map(&:as_json))
@@ -36,10 +29,6 @@ module CompletionKit
36
29
  run = Run.find(args["run_id"])
37
30
  text_result(run.responses.find(args["id"]).as_json)
38
31
  end
39
-
40
- def self.text_result(data)
41
- {content: [{type: "text", text: data.to_json}]}
42
- end
43
32
  end
44
33
  end
45
34
  end
@@ -1,6 +1,8 @@
1
1
  module CompletionKit
2
2
  module McpTools
3
3
  module Runs
4
+ extend Base
5
+
4
6
  TOOLS = {
5
7
  "runs_list" => {
6
8
  description: "List all runs",
@@ -55,15 +57,6 @@ module CompletionKit
55
57
  }
56
58
  }.freeze
57
59
 
58
- def self.definitions
59
- TOOLS.map { |name, config| {name: name, description: config[:description], inputSchema: config[:inputSchema]} }
60
- end
61
-
62
- def self.call(name, arguments)
63
- tool = TOOLS.fetch(name)
64
- send(tool[:handler], arguments)
65
- end
66
-
67
60
  def self.list(_args)
68
61
  text_result(Run.order(created_at: :desc).map(&:as_json))
69
62
  end
@@ -75,7 +68,7 @@ module CompletionKit
75
68
  def self.create(args)
76
69
  run = Run.new(args.slice("name", "prompt_id", "dataset_id", "judge_model"))
77
70
  if run.save
78
- replace_run_metrics(run, args["metric_ids"])
71
+ run.replace_metrics!(args["metric_ids"])
79
72
  text_result(run.reload.as_json)
80
73
  else
81
74
  error_result(run.errors.full_messages.join(", "))
@@ -85,7 +78,7 @@ module CompletionKit
85
78
  def self.update(args)
86
79
  run = Run.find(args["id"])
87
80
  if run.update(args.except("id", "metric_ids").slice("name", "dataset_id", "judge_model"))
88
- replace_run_metrics(run, args["metric_ids"]) if args.key?("metric_ids")
81
+ run.replace_metrics!(args["metric_ids"]) if args.key?("metric_ids")
89
82
  text_result(run.reload.as_json)
90
83
  else
91
84
  error_result(run.errors.full_messages.join(", "))
@@ -108,23 +101,6 @@ module CompletionKit
108
101
  JudgeJob.perform_later(run.id)
109
102
  text_result(run.reload.as_json)
110
103
  end
111
-
112
- def self.text_result(data)
113
- text = data.is_a?(String) ? data : data.to_json
114
- {content: [{type: "text", text: text}]}
115
- end
116
-
117
- def self.error_result(message)
118
- {content: [{type: "text", text: message}], isError: true}
119
- end
120
-
121
- def self.replace_run_metrics(run, metric_ids)
122
- return unless metric_ids
123
- run.run_metrics.delete_all
124
- Array(metric_ids).reject(&:blank?).each_with_index do |metric_id, index|
125
- run.run_metrics.create!(metric_id: metric_id, position: index + 1)
126
- end
127
- end
128
104
  end
129
105
  end
130
106
  end
@@ -3,20 +3,11 @@ module CompletionKit
3
3
  def generate_completion(prompt, options = {})
4
4
  return "Error: API endpoint not configured" unless configured?
5
5
 
6
- require "faraday"
7
- require "faraday/retry"
8
- require "json"
9
-
10
6
  model = options[:model]
11
7
  max_tokens = options[:max_tokens] || 1000
12
8
  temperature = options[:temperature] || 0.7
13
9
 
14
- conn = Faraday.new(url: api_endpoint) do |f|
15
- f.request :retry, max: 2, interval: 0.5
16
- f.adapter Faraday.default_adapter
17
- end
18
-
19
- response = conn.post do |req|
10
+ response = build_connection(api_endpoint).post do |req|
20
11
  req.url "/v1/completions"
21
12
  req.headers["Content-Type"] = "application/json"
22
13
  req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
@@ -41,11 +32,7 @@ module CompletionKit
41
32
  def available_models
42
33
  return [] unless configured?
43
34
 
44
- require "faraday"
45
- require "faraday/retry"
46
- require "json"
47
-
48
- response = Faraday.get("#{api_endpoint}/v1/models") do |req|
35
+ response = build_connection(api_endpoint).get("/v1/models") do |req|
49
36
  req.headers["Authorization"] = "Bearer #{api_key}" if api_key.present?
50
37
  end
51
38
 
@@ -9,20 +9,11 @@ module CompletionKit
9
9
  def generate_completion(prompt, options = {})
10
10
  return "Error: API key not configured" unless configured?
11
11
 
12
- require "faraday"
13
- require "faraday/retry"
14
- require "json"
15
-
16
12
  model = options[:model] || "gpt-4.1-mini"
17
13
  max_tokens = options[:max_tokens] || 1000
18
14
  temperature = options[:temperature] || 0.7
19
15
 
20
- conn = Faraday.new(url: "https://api.openai.com") do |f|
21
- f.request :retry, max: 2, interval: 0.5
22
- f.adapter Faraday.default_adapter
23
- end
24
-
25
- response = conn.post do |req|
16
+ response = build_connection("https://api.openai.com").post do |req|
26
17
  req.url "/v1/responses"
27
18
  req.headers["Content-Type"] = "application/json"
28
19
  req.headers["Authorization"] = "Bearer #{api_key}"
@@ -7,22 +7,11 @@ module CompletionKit
7
7
  def generate_completion(prompt, options = {})
8
8
  return "Error: API key not configured" unless configured?
9
9
 
10
- require "faraday"
11
- require "faraday/retry"
12
- require "json"
13
-
14
10
  model = options[:model] || "openai/gpt-4o-mini"
15
11
  max_tokens = options[:max_tokens] || 1000
16
12
  temperature = options[:temperature] || 0.7
17
13
 
18
- conn = Faraday.new(url: BASE_URL) do |f|
19
- f.options.timeout = 30
20
- f.options.open_timeout = 5
21
- f.request :retry, max: 2, interval: 0.5
22
- f.adapter Faraday.default_adapter
23
- end
24
-
25
- response = conn.post do |req|
14
+ response = build_connection(BASE_URL, timeout: 30, open_timeout: 5).post do |req|
26
15
  req.url "/chat/completions"
27
16
  req.headers["Content-Type"] = "application/json"
28
17
  req.headers["Authorization"] = "Bearer #{api_key}"
@@ -0,0 +1,15 @@
1
+ module CompletionKit
2
+ class TenantScopedUniquenessValidator < ActiveRecord::Validations::UniquenessValidator
3
+ def validate_each(record, attribute, value)
4
+ extra = Array(CompletionKit.config.tenant_scope_columns)
5
+ return super if extra.empty? && options[:scope].nil?
6
+
7
+ merged = options.merge(
8
+ scope: Array(options[:scope]) + extra,
9
+ attributes: [attribute],
10
+ class: @klass
11
+ )
12
+ self.class.superclass.new(merged).validate(record)
13
+ end
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module CompletionKit
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -8,6 +8,7 @@ module CompletionKit
8
8
  attr_accessor :openai_api_key, :anthropic_api_key, :ollama_api_key, :ollama_api_endpoint
9
9
  attr_accessor :judge_model, :high_quality_threshold, :medium_quality_threshold
10
10
  attr_accessor :username, :password, :auth_strategy, :api_token
11
+ attr_accessor :tenant_scope, :tenant_scope_columns
11
12
 
12
13
  def initialize
13
14
  @openai_api_key = ENV['OPENAI_API_KEY']
@@ -19,6 +20,10 @@ module CompletionKit
19
20
  @high_quality_threshold = 4
20
21
  @medium_quality_threshold = 3
21
22
  end
23
+
24
+ def tenant_scope_columns
25
+ @tenant_scope_columns ||= []
26
+ end
22
27
  end
23
28
 
24
29
  class << self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: completion-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Damien Bastin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-19 00:00:00.000000000 Z
11
+ date: 2026-04-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -259,6 +259,7 @@ files:
259
259
  - app/services/completion_kit/judge_service.rb
260
260
  - app/services/completion_kit/llm_client.rb
261
261
  - app/services/completion_kit/mcp_dispatcher.rb
262
+ - app/services/completion_kit/mcp_tools/base.rb
262
263
  - app/services/completion_kit/mcp_tools/datasets.rb
263
264
  - app/services/completion_kit/mcp_tools/metric_groups.rb
264
265
  - app/services/completion_kit/mcp_tools/metrics.rb
@@ -271,6 +272,7 @@ files:
271
272
  - app/services/completion_kit/open_ai_client.rb
272
273
  - app/services/completion_kit/open_router_client.rb
273
274
  - app/services/completion_kit/prompt_improvement_service.rb
275
+ - app/validators/completion_kit/tenant_scoped_uniqueness_validator.rb
274
276
  - app/views/completion_kit/api_reference/_example.html.erb
275
277
  - app/views/completion_kit/api_reference/index.html.erb
276
278
  - app/views/completion_kit/datasets/_form.html.erb