completion-kit 0.25.3 → 0.26.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.
- checksums.yaml +4 -4
- data/app/controllers/completion_kit/api/v1/provider_credentials_controller.rb +6 -3
- data/app/controllers/completion_kit/provider_credentials_controller.rb +10 -2
- data/app/jobs/completion_kit/model_discovery_job.rb +3 -0
- data/app/models/completion_kit/provider_credential.rb +36 -4
- data/app/services/completion_kit/api_config.rb +1 -1
- data/app/services/completion_kit/azure_foundry_client.rb +109 -0
- data/app/services/completion_kit/llm_client.rb +2 -0
- data/app/services/completion_kit/mcp_tools/provider_credentials.rb +12 -7
- data/app/services/completion_kit/model_discovery_service.rb +78 -9
- data/app/views/completion_kit/provider_credentials/_form.html.erb +21 -1
- data/config/routes.rb +1 -1
- data/db/migrate/20260708000001_add_api_version_to_completion_kit_provider_credentials.rb +5 -0
- data/lib/completion_kit/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c870f728fb1ea2e6cb3eb0319a5ff3102b23d6aec1d2bf6023215e61d9e8896
|
|
4
|
+
data.tar.gz: 12dcb5ff03f4efa4d61ac761d213252f9eb29c12f90619147da2daec6e16876f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53238e3533ba8fb70bbca85287367a2caf409ab65fb544d04f78c00c5a98ce2938caabcb5ccc75e75f0dfa45a84959aaaabbcd69675f7c152936b125cbcf189f
|
|
7
|
+
data.tar.gz: 289b3034cf9a8dfc28792303790afffa37d89b852bed6009efc40184b42ab6e74d6f8095e42f4e9204babf45781ac5d5c07e7ccfdb4891ebb568b442ab351a5d
|
|
@@ -30,8 +30,11 @@ module CompletionKit
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def destroy
|
|
33
|
-
@credential.destroy
|
|
34
|
-
|
|
33
|
+
if @credential.destroy
|
|
34
|
+
head :no_content
|
|
35
|
+
else
|
|
36
|
+
render_validation_errors(@credential)
|
|
37
|
+
end
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
private
|
|
@@ -43,7 +46,7 @@ module CompletionKit
|
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def credential_params
|
|
46
|
-
params.permit(:provider, :api_key, :api_endpoint)
|
|
49
|
+
params.permit(:provider, :api_key, :api_endpoint, :api_version)
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
class ProviderCredentialsController < ApplicationController
|
|
3
|
-
before_action :set_provider_credential, only: [:edit, :update, :refresh]
|
|
3
|
+
before_action :set_provider_credential, only: [:edit, :update, :refresh, :destroy]
|
|
4
4
|
|
|
5
5
|
def index
|
|
6
6
|
@provider_credentials = ProviderCredential.order(:provider)
|
|
@@ -36,6 +36,14 @@ module CompletionKit
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def destroy
|
|
40
|
+
if @provider_credential.destroy
|
|
41
|
+
redirect_to provider_credentials_path, notice: "#{@provider_credential.display_provider} provider was removed."
|
|
42
|
+
else
|
|
43
|
+
redirect_to provider_credentials_path, alert: @provider_credential.errors.full_messages.to_sentence
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
def refresh
|
|
40
48
|
@provider_credential.update_columns(discovery_status: "discovering", discovery_current: 0, discovery_total: 0)
|
|
41
49
|
@provider_credential.reload
|
|
@@ -62,7 +70,7 @@ module CompletionKit
|
|
|
62
70
|
end
|
|
63
71
|
|
|
64
72
|
def provider_credential_params
|
|
65
|
-
params.require(:provider_credential).permit(:provider, :api_key, :api_endpoint)
|
|
73
|
+
params.require(:provider_credential).permit(:provider, :api_key, :api_endpoint, :api_version)
|
|
66
74
|
end
|
|
67
75
|
end
|
|
68
76
|
end
|
|
@@ -18,6 +18,9 @@ module CompletionKit
|
|
|
18
18
|
discard_on ActiveJob::DeserializationError
|
|
19
19
|
|
|
20
20
|
rescue_from(StandardError) do |error|
|
|
21
|
+
if 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
|
|
21
24
|
credential = ProviderCredential.find(arguments.first)
|
|
22
25
|
credential.update_columns(discovery_status: "failed", discovery_error: error.message.to_s.truncate(500))
|
|
23
26
|
credential.reload
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
module CompletionKit
|
|
2
2
|
class ProviderCredential < ApplicationRecord
|
|
3
3
|
include Turbo::Broadcastable
|
|
4
|
-
PROVIDERS = %w[openai anthropic ollama openrouter].freeze
|
|
4
|
+
PROVIDERS = %w[openai anthropic ollama openrouter azure_foundry].freeze
|
|
5
5
|
PROVIDER_LABELS = {
|
|
6
6
|
"openai" => "OpenAI",
|
|
7
7
|
"anthropic" => "Anthropic",
|
|
8
8
|
"ollama" => "Ollama / OpenAI-compatible endpoint",
|
|
9
|
-
"openrouter" => "OpenRouter"
|
|
9
|
+
"openrouter" => "OpenRouter",
|
|
10
|
+
"azure_foundry" => "Azure AI Foundry"
|
|
10
11
|
}.freeze
|
|
11
12
|
|
|
12
13
|
encrypts :api_key
|
|
13
14
|
|
|
14
15
|
def as_json(options = {})
|
|
15
16
|
{
|
|
16
|
-
id: id, provider: provider, api_endpoint: api_endpoint,
|
|
17
|
+
id: id, provider: provider, api_endpoint: api_endpoint, api_version: api_version,
|
|
17
18
|
created_at: created_at, updated_at: updated_at
|
|
18
19
|
}
|
|
19
20
|
end
|
|
@@ -24,15 +25,24 @@ module CompletionKit
|
|
|
24
25
|
|
|
25
26
|
validates :provider, presence: true, inclusion: { in: PROVIDERS }
|
|
26
27
|
validates :provider, tenant_scoped_uniqueness: true
|
|
28
|
+
validates :api_endpoint, presence: true, if: :azure_foundry?
|
|
29
|
+
validates :api_version, presence: true, if: :azure_foundry?
|
|
27
30
|
validate :api_endpoint_not_internal
|
|
28
31
|
|
|
29
32
|
after_save :enqueue_discovery
|
|
33
|
+
before_destroy :ensure_not_in_use, prepend: true
|
|
34
|
+
after_destroy :destroy_discovered_models
|
|
35
|
+
|
|
36
|
+
def azure_foundry?
|
|
37
|
+
provider == "azure_foundry"
|
|
38
|
+
end
|
|
30
39
|
|
|
31
40
|
def config_hash
|
|
32
41
|
{
|
|
33
42
|
provider: provider,
|
|
34
43
|
api_key: api_key,
|
|
35
|
-
api_endpoint: api_endpoint
|
|
44
|
+
api_endpoint: api_endpoint,
|
|
45
|
+
api_version: api_version
|
|
36
46
|
}.compact
|
|
37
47
|
end
|
|
38
48
|
|
|
@@ -78,6 +88,17 @@ module CompletionKit
|
|
|
78
88
|
.maximum(:created_at)
|
|
79
89
|
end
|
|
80
90
|
|
|
91
|
+
def in_use?
|
|
92
|
+
prompt_count.positive? || judge_count.positive?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def in_use_message
|
|
96
|
+
parts = []
|
|
97
|
+
parts << "#{prompt_count} #{'prompt'.pluralize(prompt_count)}" if prompt_count.positive?
|
|
98
|
+
parts << "#{judge_count} judge #{'run'.pluralize(judge_count)}" if judge_count.positive?
|
|
99
|
+
"#{display_provider} is still in use by #{parts.to_sentence}. Remove those references before deleting it."
|
|
100
|
+
end
|
|
101
|
+
|
|
81
102
|
def broadcast_discovery_progress
|
|
82
103
|
safely_broadcast do
|
|
83
104
|
broadcast_replace_to(
|
|
@@ -108,6 +129,17 @@ module CompletionKit
|
|
|
108
129
|
|
|
109
130
|
private
|
|
110
131
|
|
|
132
|
+
def ensure_not_in_use
|
|
133
|
+
return unless in_use?
|
|
134
|
+
|
|
135
|
+
errors.add(:base, in_use_message)
|
|
136
|
+
throw :abort
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def destroy_discovered_models
|
|
140
|
+
Model.where(provider: provider).delete_all
|
|
141
|
+
end
|
|
142
|
+
|
|
111
143
|
def enqueue_discovery
|
|
112
144
|
update_columns(discovery_status: "discovering", discovery_current: 0, discovery_total: 0)
|
|
113
145
|
ModelDiscoveryJob.perform_later(id)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module CompletionKit
|
|
2
|
+
class AzureFoundryClient < LlmClient
|
|
3
|
+
def temperature_dropped?
|
|
4
|
+
@temperature_dropped == true
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def generate_completion(prompt, options = {})
|
|
8
|
+
@temperature_dropped = false
|
|
9
|
+
return "Error: Azure provider is not fully configured" unless configured?
|
|
10
|
+
return "Error: API endpoint resolves to a private address" unless ProviderEndpoint.safe?(api_endpoint)
|
|
11
|
+
|
|
12
|
+
model = options[:model]
|
|
13
|
+
max_tokens = options[:max_tokens] || 1000
|
|
14
|
+
temperature = options[:temperature] || 0.7
|
|
15
|
+
|
|
16
|
+
response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: temperature)
|
|
17
|
+
|
|
18
|
+
if response.status == 400 && temperature_unsupported?(response.body)
|
|
19
|
+
@temperature_dropped = true
|
|
20
|
+
response = post_chat(model: model, prompt: prompt, max_tokens: max_tokens, temperature: nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if response.status == 429
|
|
24
|
+
raise CompletionKit::RateLimitError.new(
|
|
25
|
+
response.body.to_s.truncate(500),
|
|
26
|
+
provider: "azure_foundry",
|
|
27
|
+
status: 429,
|
|
28
|
+
retry_after: response.headers["Retry-After"]&.to_i
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if response.success?
|
|
33
|
+
data = JSON.parse(response.body)
|
|
34
|
+
content = data.dig("choices", 0, "message", "content").to_s.strip
|
|
35
|
+
return "Error: model returned empty content" if content.empty?
|
|
36
|
+
content
|
|
37
|
+
else
|
|
38
|
+
"Error: #{response.status} - #{response.body}"
|
|
39
|
+
end
|
|
40
|
+
rescue CompletionKit::RateLimitError
|
|
41
|
+
raise
|
|
42
|
+
rescue Faraday::Error
|
|
43
|
+
raise
|
|
44
|
+
rescue => e
|
|
45
|
+
"Error: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def available_models
|
|
49
|
+
return [] unless configured?
|
|
50
|
+
return [] unless ProviderEndpoint.safe?(api_endpoint)
|
|
51
|
+
|
|
52
|
+
response = build_connection(azure_base_url).get("/openai/deployments?api-version=#{api_version}") do |req|
|
|
53
|
+
req.headers["api-key"] = api_key
|
|
54
|
+
end
|
|
55
|
+
return [] unless response.success?
|
|
56
|
+
|
|
57
|
+
JSON.parse(response.body).fetch("data", []).map { |entry| { id: entry["id"], name: entry["id"] } }
|
|
58
|
+
rescue StandardError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def configured?
|
|
63
|
+
configuration_errors.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def configuration_errors
|
|
67
|
+
errors = []
|
|
68
|
+
errors << "Azure endpoint is not configured" if api_endpoint.blank?
|
|
69
|
+
errors << "Azure API key is not configured" if api_key.blank?
|
|
70
|
+
errors << "Azure api-version is not configured" if api_version.blank?
|
|
71
|
+
errors
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def api_key
|
|
77
|
+
@config[:api_key]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def api_endpoint
|
|
81
|
+
@config[:api_endpoint]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def api_version
|
|
85
|
+
@config[:api_version]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def azure_base_url
|
|
89
|
+
api_endpoint.to_s.strip.delete_suffix("/")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def post_chat(model:, prompt:, max_tokens:, temperature:)
|
|
93
|
+
body = { messages: [{ role: "user", content: prompt }], max_tokens: max_tokens }
|
|
94
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
95
|
+
|
|
96
|
+
build_connection(azure_base_url, timeout: 30, open_timeout: 5).post do |req|
|
|
97
|
+
req.url "/openai/deployments/#{model}/chat/completions?api-version=#{api_version}"
|
|
98
|
+
req.headers["Content-Type"] = "application/json"
|
|
99
|
+
req.headers["api-key"] = api_key
|
|
100
|
+
req.body = body.to_json
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def temperature_unsupported?(body)
|
|
105
|
+
s = body.to_s
|
|
106
|
+
s.include?("temperature") && (s.include?("deprecated") || s.include?("not supported") || s.include?("Unsupported parameter"))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -19,9 +19,10 @@ module CompletionKit
|
|
|
19
19
|
inputSchema: {
|
|
20
20
|
type: "object",
|
|
21
21
|
properties: {
|
|
22
|
-
provider: {type: "string", enum: ["openai", "anthropic", "ollama", "openrouter"]},
|
|
22
|
+
provider: {type: "string", enum: ["openai", "anthropic", "ollama", "openrouter", "azure_foundry"]},
|
|
23
23
|
api_key: {type: "string"},
|
|
24
|
-
api_endpoint: {type: "string"}
|
|
24
|
+
api_endpoint: {type: "string"},
|
|
25
|
+
api_version: {type: "string"}
|
|
25
26
|
},
|
|
26
27
|
required: ["provider", "api_key"]
|
|
27
28
|
},
|
|
@@ -33,7 +34,7 @@ module CompletionKit
|
|
|
33
34
|
type: "object",
|
|
34
35
|
properties: {
|
|
35
36
|
id: {type: "integer"}, provider: {type: "string"},
|
|
36
|
-
api_key: {type: "string"}, api_endpoint: {type: "string"}
|
|
37
|
+
api_key: {type: "string"}, api_endpoint: {type: "string"}, api_version: {type: "string"}
|
|
37
38
|
},
|
|
38
39
|
required: ["id"]
|
|
39
40
|
},
|
|
@@ -55,7 +56,7 @@ module CompletionKit
|
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
def self.create(args)
|
|
58
|
-
credential = ProviderCredential.new(args.slice("provider", "api_key", "api_endpoint"))
|
|
59
|
+
credential = ProviderCredential.new(args.slice("provider", "api_key", "api_endpoint", "api_version"))
|
|
59
60
|
if credential.save
|
|
60
61
|
text_result(credential.as_json)
|
|
61
62
|
else
|
|
@@ -65,7 +66,7 @@ module CompletionKit
|
|
|
65
66
|
|
|
66
67
|
def self.update(args)
|
|
67
68
|
credential = ProviderCredential.find(args["id"])
|
|
68
|
-
if credential.update(args.except("id").slice("provider", "api_key", "api_endpoint"))
|
|
69
|
+
if credential.update(args.except("id").slice("provider", "api_key", "api_endpoint", "api_version"))
|
|
69
70
|
text_result(credential.as_json)
|
|
70
71
|
else
|
|
71
72
|
error_result(credential.errors.full_messages.join(", "))
|
|
@@ -73,8 +74,12 @@ module CompletionKit
|
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
def self.delete(args)
|
|
76
|
-
ProviderCredential.find(args["id"])
|
|
77
|
-
|
|
77
|
+
credential = ProviderCredential.find(args["id"])
|
|
78
|
+
if credential.destroy
|
|
79
|
+
text_result("Provider credential #{args["id"]} deleted")
|
|
80
|
+
else
|
|
81
|
+
error_result(credential.errors.full_messages.join(", "))
|
|
82
|
+
end
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
end
|
|
@@ -6,18 +6,18 @@ module CompletionKit
|
|
|
6
6
|
class ModelDiscoveryService
|
|
7
7
|
class DiscoveryError < StandardError; end
|
|
8
8
|
|
|
9
|
+
AZURE_HOST_SUFFIXES = [".openai.azure.com", ".services.ai.azure.com"].freeze
|
|
10
|
+
|
|
9
11
|
def initialize(config:)
|
|
10
12
|
@provider = config[:provider]
|
|
11
13
|
@api_key = config[:api_key]
|
|
12
14
|
@api_endpoint = config[:api_endpoint]
|
|
15
|
+
@api_version = config[:api_version]
|
|
13
16
|
end
|
|
14
17
|
|
|
15
18
|
def refresh!(force: false, &on_progress)
|
|
16
19
|
discovered = fetch_models
|
|
17
20
|
reconcile(discovered)
|
|
18
|
-
# OpenRouter publishes capability metadata (output modalities, etc.), so we
|
|
19
|
-
# derive everything from the model list and skip live probing entirely.
|
|
20
|
-
# Judging stays unknown ("?") until a real run proves it.
|
|
21
21
|
return if @provider == "openrouter"
|
|
22
22
|
|
|
23
23
|
reset_failed_generation if force
|
|
@@ -32,6 +32,7 @@ module CompletionKit
|
|
|
32
32
|
when "anthropic" then fetch_anthropic_models
|
|
33
33
|
when "openrouter" then fetch_openrouter_models
|
|
34
34
|
when "ollama" then fetch_ollama_models
|
|
35
|
+
when "azure_foundry" then fetch_azure_foundry_models
|
|
35
36
|
else []
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -106,19 +107,72 @@ module CompletionKit
|
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
def fetch_ollama_models
|
|
109
|
-
raise DiscoveryError, "
|
|
110
|
+
raise DiscoveryError, "A model endpoint URL is required." if @api_endpoint.blank?
|
|
110
111
|
base_url = ollama_root_url
|
|
111
112
|
response = fetch_connection(base_url).get("/v1/models") do |req|
|
|
112
113
|
req.headers["Authorization"] = "Bearer #{@api_key}" if @api_key.present?
|
|
113
114
|
end
|
|
114
|
-
|
|
115
|
+
raise DiscoveryError, custom_endpoint_error_message(response) unless response.success?
|
|
115
116
|
JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
|
|
116
117
|
end
|
|
117
118
|
|
|
119
|
+
def custom_endpoint_error_message(response)
|
|
120
|
+
detail = extract_provider_error_message(response.body)
|
|
121
|
+
case response.status
|
|
122
|
+
when 401, 403
|
|
123
|
+
with_detail("The endpoint rejected the API key (#{response.status}).", detail)
|
|
124
|
+
when 404
|
|
125
|
+
custom_endpoint_404_message
|
|
126
|
+
when 429
|
|
127
|
+
"The endpoint rate-limited the model-list request (429). Try again shortly."
|
|
128
|
+
else
|
|
129
|
+
with_detail("The endpoint at #{custom_endpoint_host} did not return an OpenAI-compatible model list at /v1/models (#{response.status}).", detail)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def custom_endpoint_404_message
|
|
134
|
+
message = "No OpenAI-compatible model list was found at #{custom_endpoint_host}/v1/models (404). Check that the base URL is correct."
|
|
135
|
+
return message unless azure_custom_host?
|
|
136
|
+
"#{message} This looks like an Azure endpoint; add it with the Azure AI Foundry provider, which uses an api-version and an api-key header."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def with_detail(message, detail)
|
|
140
|
+
detail.present? ? "#{message} #{detail}" : message
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def custom_endpoint_host
|
|
144
|
+
ProviderEndpoint.parse(@api_endpoint)&.host || @api_endpoint.to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def azure_custom_host?
|
|
148
|
+
host = custom_endpoint_host.to_s.downcase
|
|
149
|
+
AZURE_HOST_SUFFIXES.any? { |suffix| host.end_with?(suffix) }
|
|
150
|
+
end
|
|
151
|
+
|
|
118
152
|
def ollama_root_url
|
|
119
153
|
@api_endpoint.to_s.strip.delete_suffix("/").delete_suffix("/v1")
|
|
120
154
|
end
|
|
121
155
|
|
|
156
|
+
def fetch_azure_foundry_models
|
|
157
|
+
raise DiscoveryError, "An Azure endpoint URL is required." if @api_endpoint.blank?
|
|
158
|
+
raise DiscoveryError, "An Azure api-version is required." if @api_version.blank?
|
|
159
|
+
|
|
160
|
+
response = fetch_connection(azure_base_url).get("/openai/deployments?api-version=#{@api_version}") do |req|
|
|
161
|
+
req.headers["api-key"] = @api_key
|
|
162
|
+
end
|
|
163
|
+
raise DiscoveryError, azure_error_message(response) unless response.success?
|
|
164
|
+
JSON.parse(response.body).fetch("data", []).map { |e| { id: e["id"], display_name: e["id"] } }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def azure_base_url
|
|
168
|
+
@api_endpoint.to_s.strip.delete_suffix("/")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def azure_error_message(response)
|
|
172
|
+
detail = extract_provider_error_message(response.body)
|
|
173
|
+
with_detail("Azure did not return a deployments list at /openai/deployments (#{response.status}). Check the endpoint base URL and api-version.", detail)
|
|
174
|
+
end
|
|
175
|
+
|
|
122
176
|
def reconcile(discovered)
|
|
123
177
|
api_model_ids = discovered.map { |m| m[:id] }
|
|
124
178
|
meta_by_id = discovered.index_by { |m| m[:id] }
|
|
@@ -150,7 +204,7 @@ module CompletionKit
|
|
|
150
204
|
supports_generation = meta[:supports_generation] != false
|
|
151
205
|
attrs.merge!(
|
|
152
206
|
supports_generation: supports_generation,
|
|
153
|
-
supports_judging:
|
|
207
|
+
supports_judging: supports_generation,
|
|
154
208
|
probed_at: Time.current,
|
|
155
209
|
status: supports_generation ? "active" : "failed"
|
|
156
210
|
)
|
|
@@ -163,14 +217,13 @@ module CompletionKit
|
|
|
163
217
|
|
|
164
218
|
def reconcile_existing_model(model, meta)
|
|
165
219
|
if @provider == "openrouter"
|
|
166
|
-
# Re-derive generation capability from the published metadata every refresh
|
|
167
|
-
# (fixes models discovered before capability metadata was used). Leave
|
|
168
|
-
# supports_judging alone — it's "learned" from successful runs.
|
|
169
220
|
supports_generation = meta[:supports_generation] != false
|
|
170
221
|
model.update!(
|
|
171
222
|
display_name: meta[:display_name].presence || model.display_name,
|
|
172
223
|
supports_generation: supports_generation,
|
|
224
|
+
supports_judging: supports_generation,
|
|
173
225
|
generation_error: nil,
|
|
226
|
+
judging_error: nil,
|
|
174
227
|
probed_at: Time.current,
|
|
175
228
|
status: supports_generation ? "active" : "failed",
|
|
176
229
|
retired_at: nil
|
|
@@ -287,6 +340,7 @@ module CompletionKit
|
|
|
287
340
|
when "openai" then openai_probe(model_id, input, max_tokens)
|
|
288
341
|
when "anthropic" then anthropic_probe(model_id, input, max_tokens)
|
|
289
342
|
when "ollama" then ollama_probe(model_id, input, max_tokens)
|
|
343
|
+
when "azure_foundry" then azure_foundry_probe(model_id, input, max_tokens)
|
|
290
344
|
else raise ArgumentError, "Unsupported probe provider: #{@provider}"
|
|
291
345
|
end
|
|
292
346
|
end
|
|
@@ -366,5 +420,20 @@ module CompletionKit
|
|
|
366
420
|
req.body = { model: model_id, messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
|
|
367
421
|
end
|
|
368
422
|
end
|
|
423
|
+
|
|
424
|
+
def azure_foundry_probe(model_id, input, max_tokens)
|
|
425
|
+
conn = Faraday.new(url: azure_base_url) do |f|
|
|
426
|
+
f.options.timeout = 60
|
|
427
|
+
f.options.open_timeout = 5
|
|
428
|
+
f.request :retry, max: 1, interval: 0.5
|
|
429
|
+
f.adapter Faraday.default_adapter
|
|
430
|
+
end
|
|
431
|
+
conn.post do |req|
|
|
432
|
+
req.url "/openai/deployments/#{model_id}/chat/completions?api-version=#{@api_version}"
|
|
433
|
+
req.headers["Content-Type"] = "application/json"
|
|
434
|
+
req.headers["api-key"] = @api_key
|
|
435
|
+
req.body = { messages: [{ role: "user", content: input }], max_tokens: max_tokens }.to_json
|
|
436
|
+
end
|
|
437
|
+
end
|
|
369
438
|
end
|
|
370
439
|
end
|
|
@@ -24,11 +24,31 @@
|
|
|
24
24
|
|
|
25
25
|
<div class="ck-field">
|
|
26
26
|
<%= form.label :api_endpoint, "API endpoint", class: "ck-label" %>
|
|
27
|
-
<%= form.text_field :api_endpoint, class: "ck-input", placeholder: "
|
|
27
|
+
<%= form.text_field :api_endpoint, class: "ck-input", placeholder: "Needed for Ollama, Azure AI Foundry, or custom OpenAI-compatible endpoints", **ck_field_aria(form, :api_endpoint) %>
|
|
28
28
|
<%= ck_field_error(form, :api_endpoint) %>
|
|
29
29
|
</div>
|
|
30
30
|
|
|
31
|
+
<div class="ck-field">
|
|
32
|
+
<%= form.label :api_version, "API version", class: "ck-label" %>
|
|
33
|
+
<%= form.text_field :api_version, class: "ck-input", placeholder: "Only needed for Azure AI Foundry (e.g. 2024-10-21)", **ck_field_aria(form, :api_version) %>
|
|
34
|
+
<%= ck_field_error(form, :api_version) %>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<% if provider_credential.persisted? && provider_credential.in_use? %>
|
|
38
|
+
<p class="ck-field-hint"><%= provider_credential.in_use_message %></p>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
31
41
|
<div class="ck-actions">
|
|
42
|
+
<% if provider_credential.persisted? && !provider_credential.in_use? %>
|
|
43
|
+
<%= button_to provider_credential_path(provider_credential), method: :delete,
|
|
44
|
+
form_class: "inline-block",
|
|
45
|
+
class: "ck-icon-btn",
|
|
46
|
+
title: "Delete provider",
|
|
47
|
+
"aria-label": "Delete provider",
|
|
48
|
+
data: { turbo_confirm: "Delete the #{provider_credential.display_provider} provider and its discovered models? This can't be undone." } do %>
|
|
49
|
+
<%= heroicon_tag "trash", variant: :outline, size: 16, "aria-hidden": "true" %>
|
|
50
|
+
<% end %>
|
|
51
|
+
<% end %>
|
|
32
52
|
<%= link_to "Cancel", provider_credentials_path, class: ck_button_classes(:light, variant: :outline), tabindex: "0" %>
|
|
33
53
|
<%= form.submit(provider_credential.persisted? ? "Save provider" : "Create provider", class: ck_button_classes(:dark)) %>
|
|
34
54
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -51,7 +51,7 @@ CompletionKit::Engine.routes.draw do
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
resources :provider_credentials, only: [:index, :new, :create, :edit, :update] do
|
|
54
|
+
resources :provider_credentials, only: [:index, :new, :create, :edit, :update, :destroy] do
|
|
55
55
|
post :refresh, on: :member
|
|
56
56
|
get :statuses, on: :collection
|
|
57
57
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: completion-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.26.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Damien Bastin
|
|
@@ -300,6 +300,7 @@ files:
|
|
|
300
300
|
- app/services/completion_kit/agreement_math.rb
|
|
301
301
|
- app/services/completion_kit/anthropic_client.rb
|
|
302
302
|
- app/services/completion_kit/api_config.rb
|
|
303
|
+
- app/services/completion_kit/azure_foundry_client.rb
|
|
303
304
|
- app/services/completion_kit/checks/contains.rb
|
|
304
305
|
- app/services/completion_kit/checks/equals.rb
|
|
305
306
|
- app/services/completion_kit/checks/expected_resolver.rb
|
|
@@ -476,6 +477,7 @@ files:
|
|
|
476
477
|
- db/migrate/20260629000002_add_check_type_to_completion_kit_metric_versions.rb
|
|
477
478
|
- db/migrate/20260629000003_add_passed_to_completion_kit_reviews.rb
|
|
478
479
|
- db/migrate/20260706000001_add_expected_column_to_completion_kit_runs.rb
|
|
480
|
+
- db/migrate/20260708000001_add_api_version_to_completion_kit_provider_credentials.rb
|
|
479
481
|
- lib/completion-kit.rb
|
|
480
482
|
- lib/completion_kit.rb
|
|
481
483
|
- lib/completion_kit/concurrency_check.rb
|