patient_llm 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7873a0b7daf57787415d4484495a17f130cbf3a14137613799651aa0007f2d63
4
- data.tar.gz: 728755787c1a52c805ebd70eaf163fae702d55b9525325b101b9dae571ad1652
3
+ metadata.gz: c5a3fc235def2976b4a57107239ab72e51a50a2b3abdfed579c0c0d4ea6f681d
4
+ data.tar.gz: 77b63683fc706598ab29e2fa710d9d6650c568712301e1ff4d87bd8a50668228
5
5
  SHA512:
6
- metadata.gz: c101043564f214413f1a50b3536d56eff6f611e227013cc26507fd7b8465f1554bb6922de98f736f0712bda809afa2654bf1c366740c8b5ca3e673074e5738d7
7
- data.tar.gz: 80d9a476d5fbe99a96946558a1d847c7faad08250584f9d0b5b452d6faeb906066d64b9c5e822139aed84f22d41c0059e035d59cefc9dc75979b8c4bffb7fd20
6
+ metadata.gz: e43ffc0679e0ab348e8462884042ba538d734101acfb5b7ecb5e4058167dc3fdd7aa50ad953a091695faae5ad1ce4c25ff1b13614c2ac553587b3568115e8f9a
7
+ data.tar.gz: 034554bf031c5696ddb2bf877463f9cbcf5e57e32cdc39563975de22f0ea6cde189be72cfe053ea1338144b320254196ed51589ded11ba753161e4af544de3e4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.3.0
8
+
9
+ ### Changed
10
+
11
+ - Renamed the `completion_path` argument to `path` on both `PatientLLM.ask` and provider configuration. The `completion_path` name is still accepted as a deprecated alias and now emits a deprecation warning.
12
+ - The path is now joined with the base URL using `URI.join` to ensure proper handling of relative paths in the `path` argument. If the path starts with a slash, it will be treated as an absolute path and will replace the base URL's path. If it does not start with a slash, it will be treated as a relative path and will be appended to the base URL's path.
13
+
14
+ ## 0.2.0
15
+
16
+ ### Added
17
+
18
+ - Requires authentication headers to be set up as secrets using `PatientHttp.secret`. This is enforced and an error will be raised if plain text values are included for any of the following headers in the provider configuration: `authorization`, `x-api-key`, `x-goog-api-key`, `api-key`.
19
+
20
+ ### Changed
21
+
22
+ - Requires patient_http version 1.1 or higher.
23
+
7
24
  ## 0.1.0
8
25
 
9
26
  ### Added
data/README.md CHANGED
@@ -27,25 +27,30 @@ Without a handler, `PatientLLM.ask` raises `RuntimeError: No request handler reg
27
27
 
28
28
  ### Configuration
29
29
 
30
- Register your LLM providers with their API base URLs and authentication headers:
30
+ Register your LLM providers with their API base URLs and authentication headers. Authentication headers must be registered using the PatientHttp secrets manager. This ensures that these values are never included in the serialized payloads in the job queue, and are only attached to the request at dispatch time.
31
31
 
32
32
  ```ruby
33
33
  PatientLLM.configure do |config|
34
34
  config.provider :openai,
35
35
  url: "https://api.openai.com",
36
- headers: {"Authorization" => "Bearer #{ENV["OPENAI_API_KEY"]}"}
36
+ headers: {"authorization" => PatientHttp.secret("openai.bearer_token")}
37
37
 
38
38
  config.provider :anthropic,
39
39
  url: "https://api.anthropic.com",
40
- headers: {"x-api-key" => ENV["ANTHROPIC_API_KEY"]},
40
+ headers: {"x-api-key" => PatientHttp.secret("anthropic.api_key")},
41
41
  serializer: :messages
42
42
  end
43
+
44
+ # Register the API keys as secrets with the PatientHttp secrets manager. This example
45
+ # is for the Sidekiq integration, but the pattern is the same for SolidQueue.
46
+ PatientHttp::Sidekiq.configure do |config|
47
+ config.register_secret("openai.bearer_token") { "Bearer #{ENV.fetch("OPENAI_API_KEY")}" }
48
+ config.register_secret("anthropic.api_key") { ENV.fetch("ANTHROPIC_API_KEY") }
49
+ end
43
50
  ```
44
51
 
45
52
  > [!NOTE]
46
- > Authentication headers configured on the provider are re-attached to every request at dispatch time and are persisted in the asynchronous job payload.
47
- >
48
- > You should set up encryption for you job payloads to prevent leaking credentials. See the documentation for [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq#sensitive-data-handling) or [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue#sensitive-data-handling) for details.
53
+ > You can also set up encryption for your job payloads to ensure the entire serialized payload is always encrypted in the job queue. See the documentation for [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq#sensitive-data-handling) or [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue#sensitive-data-handling) for details.
49
54
 
50
55
  ### Creating a Callback Class
51
56
 
@@ -180,9 +185,9 @@ session.max_output_tokens = 1000
180
185
  PatientLLM.ask(session,
181
186
  provider: :openai,
182
187
  callback: LLMCallback,
183
- url: "http://localhost:1234", # Override the provider's base URL
188
+ url: "http://localhost:1234", # Override the provider's base URL
184
189
  serializer: :messages, # Override the API format
185
- completion_path: "/chat/completions", # Override the endpoint path
190
+ path: "/chat/completions", # Override the endpoint path
186
191
  headers: {"X-Custom" => "value"}, # Additional HTTP headers
187
192
  params: {max_completion_tokens: 1000} # Additional request parameters
188
193
  )
@@ -190,24 +195,24 @@ PatientLLM.ask(session,
190
195
 
191
196
  ### URL composition
192
197
 
193
- The full request URL is built by concatenating the base URL (from the provider registry or the `url:` option) with the `completion_path`. When you don't set `completion_path`, it defaults to the path for the active serializer (`/v1/chat/completions` for `:chat_completion`, `/v1/responses` for `:open_responses`, `/v1/messages` for `:messages`, `/converse` for `:converse`, `/v1beta/models/{model}:generateContent` for `:gemini`). A `{model}` placeholder in the path is replaced with the session's model at dispatch time, which is how the Gemini default targets Google's `/v1beta/models/{model}:generateContent` endpoint. Trailing slashes on the base and leading slashes on the path are normalized, so:
198
+ The full request URL is built by concatenating the base URL (from the provider registry or the `url:` option) with the `path`. When you don't set `path`, it defaults to the path for the active serializer (`/v1/chat/completions` for `:chat_completion`, `/v1/responses` for `:open_responses`, `/v1/messages` for `:messages`, `/converse` for `:converse`, `/v1beta/models/{model}:generateContent` for `:gemini`). A `{model}` placeholder in the path is replaced with the session's model at dispatch time, which is how the Gemini default targets Google's `/v1beta/models/{model}:generateContent` endpoint. Trailing slashes on the base and leading slashes on the path are normalized, so:
194
199
 
195
200
  ```
196
- url = "https://api.openai.com" completion_path = "/v1/chat/completions"
201
+ url = "https://api.openai.com" path = "/v1/chat/completions"
197
202
  -> https://api.openai.com/v1/chat/completions
198
203
 
199
- url = "http://localhost:1234" completion_path = "/v1/chat/completions"
204
+ url = "http://localhost:1234" path = "/v1/chat/completions"
200
205
  -> http://localhost:1234/v1/chat/completions
201
206
  ```
202
207
 
203
- If your base URL already includes a `/v1` prefix, override the completion path to avoid duplication:
208
+ If your base URL already includes a `/v1` prefix, override the path to avoid duplication:
204
209
 
205
210
  ```ruby
206
211
  PatientLLM.ask(session,
207
212
  provider: :openai,
208
213
  callback: LLMCallback,
209
214
  url: "https://my-gateway.internal/openai/v1",
210
- completion_path: "/chat/completions"
215
+ path: "chat/completions"
211
216
  )
212
217
  ```
213
218
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.3.0
@@ -213,7 +213,7 @@ module PatientLLM
213
213
  # Restore per-request overrides
214
214
  ask_kwargs[:url] = request_options["url"] if request_options["url"]
215
215
  ask_kwargs[:serializer] = request_options["serializer"].to_sym if request_options["serializer"]
216
- ask_kwargs[:completion_path] = request_options["completion_path"] if request_options["completion_path"]
216
+ ask_kwargs[:path] = request_options["path"] if request_options["path"]
217
217
  ask_kwargs[:headers] = request_options["headers"] if request_options["headers"]
218
218
  ask_kwargs[:params] = request_options["params"] if request_options["params"]
219
219
 
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientLLM
4
+ # Headers that must be setup to use the secrets manager. If any of these headers
5
+ # are included in the provider configuration, an error will be raised unless their
6
+ # values are set up as secrets using `PatientHttp.secret`.
7
+ AUTHENTICATION_HEADERS = ["authorization", "x-api-key", "x-goog-api-key", "api-key"].freeze
8
+
4
9
  # Configuration for provider registry.
5
10
  #
6
11
  # @example
@@ -21,20 +26,28 @@ module PatientLLM
21
26
  # @param url [String] Base URL for the provider API
22
27
  # @param headers [Hash] Default headers for requests
23
28
  # @param serializer [Symbol] API format (:chat_completion, :open_responses, :messages, :converse, :gemini)
24
- # @param completion_path [String, nil] Override the default endpoint path
29
+ # @param path [String, nil] Override the default endpoint path
30
+ # @param completion_path [String, nil] Deprecated alias for +path+
25
31
  # @param params [Hash] Additional parameters to merge into every request payload
26
32
  # @return [void]
27
- def provider(name, url:, headers: {}, serializer: :chat_completion, completion_path: nil, params: {})
33
+ def provider(name, url:, headers: {}, serializer: :chat_completion, path: nil, completion_path: nil, params: {})
34
+ if completion_path
35
+ warn "PatientLLM::Configuration#provider: the `completion_path:` argument is deprecated; use `path:` instead", uplevel: 1
36
+ path ||= completion_path
37
+ end
38
+
28
39
  sym = serializer.to_sym
29
40
  unless PatientLLM::VALID_SERIALIZERS.include?(sym)
30
41
  raise ArgumentError, "Unknown serializer: #{sym.inspect}. Valid options: #{PatientLLM::VALID_SERIALIZERS.map(&:inspect).join(", ")}"
31
42
  end
32
43
 
44
+ ensure_auth_headers_use_secrets!(headers)
45
+
33
46
  @providers[name.to_s] = {
34
47
  url: url,
35
48
  headers: headers,
36
49
  serializer: sym,
37
- completion_path: completion_path,
50
+ path: path,
38
51
  params: params
39
52
  }
40
53
  end
@@ -46,5 +59,18 @@ module PatientLLM
46
59
  def lookup(name)
47
60
  @providers[name&.to_s]
48
61
  end
62
+
63
+ private
64
+
65
+ def ensure_auth_headers_use_secrets!(headers)
66
+ headers.each do |header_name, header_value|
67
+ normalized_header_name = header_name.to_s.downcase
68
+ next unless AUTHENTICATION_HEADERS.include?(normalized_header_name)
69
+
70
+ if header_value && !header_value.is_a?(PatientHttp::SecretReference)
71
+ raise ArgumentError, "Authentication header #{header_name} must be set up as a secret using `PatientHttp.secret`"
72
+ end
73
+ end
74
+ end
49
75
  end
50
76
  end
data/lib/patient_llm.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "patient_http"
4
4
  require "prompt_builder"
5
+ require "uri"
5
6
 
6
7
  module PatientLLM
7
8
  VERSION = File.read(File.join(__dir__, "../VERSION")).strip
@@ -16,11 +17,11 @@ module PatientLLM
16
17
  # dispatch time, matching Google's `/v1beta/models/{model}:generateContent`
17
18
  # endpoint.
18
19
  SERIALIZER_PATHS = {
19
- chat_completion: "/v1/chat/completions",
20
- open_responses: "/v1/responses",
21
- messages: "/v1/messages",
22
- converse: "/converse",
23
- gemini: "/v1beta/models/{model}:generateContent"
20
+ chat_completion: "v1/chat/completions",
21
+ open_responses: "v1/responses",
22
+ messages: "v1/messages",
23
+ converse: "converse",
24
+ gemini: "v1beta/models/{model}:generateContent"
24
25
  }.freeze
25
26
 
26
27
  # Required version header for the Anthropic Messages API.
@@ -62,11 +63,17 @@ module PatientLLM
62
63
  # @param callback_args [Hash] Custom arguments passed through to the callback
63
64
  # @param url [String, nil] Override the provider's base URL for this request
64
65
  # @param serializer [Symbol, nil] Override the provider's serializer for this request
65
- # @param completion_path [String, nil] Override the endpoint path for this request
66
+ # @param path [String, nil] Override the endpoint path for this request
67
+ # @param completion_path [String, nil] Deprecated alias for +path+
66
68
  # @param headers [Hash, nil] Additional headers merged on top of provider headers
67
69
  # @param params [Hash, nil] Additional params merged into the request payload
68
70
  # @return [Object] Handler-specific identifier for the enqueued request
69
- def ask(session, provider:, callback:, callback_args: {}, url: nil, serializer: nil, completion_path: nil, headers: nil, params: nil, tool_iteration: 0, original_request_id: nil) # :nodoc: tool_iteration and original_request_id are internal
71
+ def ask(session, provider:, callback:, callback_args: {}, url: nil, serializer: nil, path: nil, completion_path: nil, headers: nil, params: nil, tool_iteration: 0, original_request_id: nil) # :nodoc: tool_iteration and original_request_id are internal
72
+ if completion_path
73
+ warn "PatientLLM.ask: the `completion_path:` argument is deprecated; use `path:` instead", uplevel: 1
74
+ path ||= completion_path
75
+ end
76
+
70
77
  provider_config = self.provider(provider) || {}
71
78
  provider_name = provider.to_s
72
79
 
@@ -79,9 +86,9 @@ module PatientLLM
79
86
 
80
87
  resolved_serializer = (serializer || provider_config[:serializer] || :chat_completion).to_sym
81
88
  validate_serializer!(resolved_serializer)
82
- resolved_completion_path = completion_path || provider_config[:completion_path] || SERIALIZER_PATHS[resolved_serializer] || "/v1/chat/completions"
83
- if resolved_completion_path.include?("{model}")
84
- resolved_completion_path = resolved_completion_path.gsub("{model}", session.model.to_s)
89
+ resolved_path = path || provider_config[:path] || SERIALIZER_PATHS[resolved_serializer] || "/v1/chat/completions"
90
+ if resolved_path.include?("{model}")
91
+ resolved_path = resolved_path.gsub("{model}", session.model.to_s)
85
92
  end
86
93
  resolved_headers = (provider_config[:headers] || {}).merge(headers || {})
87
94
  if resolved_serializer == :messages && !resolved_headers.key?("anthropic-version")
@@ -92,12 +99,12 @@ module PatientLLM
92
99
  payload = session.request_payload(resolved_serializer)
93
100
  payload = deep_merge(payload, deep_stringify_keys(resolved_params)) unless resolved_params.empty?
94
101
 
95
- request_url = join_url(resolved_url, resolved_completion_path)
102
+ request_url = join_url(resolved_url, resolved_path)
96
103
 
97
104
  request_options = {}
98
105
  request_options["url"] = url if url
99
106
  request_options["serializer"] = serializer.to_s if serializer
100
- request_options["completion_path"] = completion_path if completion_path
107
+ request_options["path"] = path if path
101
108
  request_options["headers"] = headers if headers && !headers.empty?
102
109
  request_options["params"] = params if params && !params.empty?
103
110
 
@@ -128,7 +135,9 @@ module PatientLLM
128
135
  end
129
136
 
130
137
  def join_url(base, path)
131
- "#{base.sub(%r{/\z}, "")}/#{path.to_s.sub(%r{\A/}, "")}"
138
+ base_uri = URI.parse(base)
139
+ base_uri.path = "#{base_uri.path}/" unless base_uri.path&.end_with?("/")
140
+ URI.join(base_uri, path).to_s
132
141
  end
133
142
 
134
143
  def deep_merge(hash1, hash2)
data/patient_llm.gemspec CHANGED
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
37
37
 
38
38
  spec.required_ruby_version = ">= 3.0"
39
39
 
40
- spec.add_dependency "patient_http"
40
+ spec.add_dependency "patient_http", "~> 1.1"
41
41
  spec.add_dependency "prompt_builder"
42
42
 
43
43
  spec.add_development_dependency "bundler"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: patient_http
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '1.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - ">="
23
+ - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '1.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: prompt_builder
28
28
  requirement: !ruby/object:Gem::Requirement