ai_record_finder 0.1.2 → 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.
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  It is designed for B2B Rails applications that need strict query safety, tenant boundaries, and model-level authorization.
6
6
 
7
- Documentation homepage: <https://github.com/JijoBose/ai_record_finder/blob/main/docs/HOME.md>
7
+ Documentation homepage: `docs/HOME.md`
8
8
 
9
9
  ## Installation
10
10
 
@@ -38,6 +38,49 @@ AIRecordFinder.configure do |config|
38
38
  end
39
39
  ```
40
40
 
41
+ ## Providers
42
+
43
+ `ai_record_finder` ships with native adapters for two providers. Select one
44
+ with `config.provider` (default: `:openai`).
45
+
46
+ ### OpenAI (default)
47
+
48
+ ```ruby
49
+ AIRecordFinder.configure do |config|
50
+ config.provider = :openai
51
+ config.api_key = ENV.fetch("OPENAI_API_KEY")
52
+ # config.model_name defaults to "gpt-4o-mini"
53
+ end
54
+ ```
55
+
56
+ This also covers any OpenAI-compatible endpoint (Azure OpenAI, OpenRouter,
57
+ LiteLLM, Ollama, vLLM, ...) — point `api_base_url` at the gateway:
58
+
59
+ ```ruby
60
+ config.api_base_url = "https://openrouter.ai/api/v1"
61
+ ```
62
+
63
+ ### Anthropic (native Messages API)
64
+
65
+ The Anthropic adapter talks to the native `/v1/messages` API (`x-api-key`
66
+ auth, `anthropic-version` header, top-level `system` prompt, `content`-block
67
+ responses) — not the OpenAI-compatibility shim.
68
+
69
+ ```ruby
70
+ AIRecordFinder.configure do |config|
71
+ config.provider = :anthropic
72
+ config.api_key = ENV.fetch("ANTHROPIC_API_KEY")
73
+ config.model_name = "claude-sonnet-4-6" # default; override for your account access
74
+
75
+ # Anthropic-specific knobs:
76
+ config.max_tokens = 1024 # required by the Messages API; default 1024
77
+ config.anthropic_version = "2023-06-01" # default
78
+ end
79
+ ```
80
+
81
+ When `provider` is set, `model_name` and `api_base_url` resolve to that
82
+ provider's defaults unless you assign them explicitly.
83
+
41
84
  ## Usage
42
85
 
43
86
  ```ruby
@@ -70,10 +113,14 @@ For associated-table constraints, reference fields as `association.column` in na
70
113
 
71
114
  Core components:
72
115
 
73
- - `AIRecordFinder::Configuration`: runtime safety and API settings.
116
+ - `AIRecordFinder::Configuration`: runtime safety, provider, and API settings.
74
117
  - `AIRecordFinder::SchemaIntrospector`: model table/column/association/enum summary.
75
118
  - `AIRecordFinder::PromptBuilder`: strict system prompt with schema and DSL contract.
76
- - `AIRecordFinder::Client`: OpenAI-compatible HTTP transport (Faraday).
119
+ - `AIRecordFinder::Providers`: provider registry and per-vendor transports
120
+ (`Providers::OpenAI`, `Providers::Anthropic`) built on a shared
121
+ `Providers::Base` (Faraday).
122
+ - `AIRecordFinder::Client`: transport facade that selects and delegates to the
123
+ configured provider.
77
124
  - `AIRecordFinder::AIAdapter`: AI response extraction and JSON parsing.
78
125
  - `AIRecordFinder::DSLParser`: validates DSL structure and values.
79
126
  - `AIRecordFinder::SafetyGuard`: model authorization, limit policies, join policies, tenant scope.
@@ -86,6 +133,7 @@ Core components:
86
133
  - `AIRecordFinder::InvalidDSL`
87
134
  - `AIRecordFinder::AIResponseError`
88
135
  - `AIRecordFinder::UnauthorizedModel`
136
+ - `AIRecordFinder::ConfigurationError` (missing API key, unknown provider)
89
137
 
90
138
  ## Testing
91
139
 
data/docs/HOME.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # AIRecordFinder Documentation
2
2
 
3
- Project documentation is publicly available on GitHub.
3
+ This repository is private and uses project-owned documentation.
4
4
 
5
5
  ## Start Here
6
6
 
7
- - Developer guide: <https://github.com/JijoBose/ai_record_finder/blob/main/docs/DEVELOPER_GUIDE.md>
8
- - Package overview: <https://github.com/JijoBose/ai_record_finder/blob/main/README.md>
9
- - Release history: <https://github.com/JijoBose/ai_record_finder/blob/main/CHANGELOG.md>
7
+ - Developer guide: `docs/DEVELOPER_GUIDE.md`
8
+ - Package overview: `README.md`
9
+ - Release history: `CHANGELOG.md`
10
10
 
11
11
  ## Scope
12
12
 
@@ -1,69 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "json"
3
+ require_relative "providers"
5
4
 
6
5
  module AIRecordFinder
7
- # HTTP client for OpenAI-compatible chat completion APIs.
6
+ # Transport facade. Selects the provider configured via
7
+ # `configuration.provider` and delegates chat completion to it.
8
+ #
9
+ # Kept as a stable entry point so callers (and AIAdapter) depend on a single
10
+ # `#chat_completion(system_prompt:, user_prompt:)` interface regardless of
11
+ # whether the backing provider is OpenAI, Anthropic, or another vendor.
8
12
  class Client
9
- CHAT_COMPLETIONS_PATH = "/chat/completions"
10
-
11
- def initialize(configuration:)
12
- @configuration = configuration
13
- validate_configuration!
13
+ def initialize(configuration:, connection: nil)
14
+ @provider = Providers.build(configuration: configuration, connection: connection)
14
15
  end
15
16
 
16
17
  def chat_completion(system_prompt:, user_prompt:)
17
- response = connection.post(CHAT_COMPLETIONS_PATH) do |req|
18
- req.headers["Authorization"] = "Bearer #{@configuration.api_key}"
19
- req.headers["Content-Type"] = "application/json"
20
- req.body = JSON.generate(payload(system_prompt, user_prompt))
21
- end
22
-
23
- parsed = parse_body(response.body)
24
- extract_content(parsed)
25
- rescue Faraday::Error => e
26
- raise AIResponseError, "AI request failed: #{e.message}"
27
- end
28
-
29
- private
30
-
31
- def connection
32
- @connection ||= Faraday.new(url: @configuration.api_base_url) do |f|
33
- f.options.timeout = @configuration.request_timeout
34
- f.options.open_timeout = @configuration.request_timeout
35
- f.adapter Faraday.default_adapter
36
- end
37
- end
38
-
39
- def payload(system_prompt, user_prompt)
40
- {
41
- model: @configuration.model_name,
42
- temperature: @configuration.temperature,
43
- messages: [
44
- { role: "system", content: system_prompt },
45
- { role: "user", content: user_prompt }
46
- ]
47
- }
48
- end
49
-
50
- def parse_body(body)
51
- JSON.parse(body)
52
- rescue JSON::ParserError
53
- raise AIResponseError, "AI response body is not valid JSON"
54
- end
55
-
56
- def extract_content(parsed)
57
- choices = parsed["choices"]
58
- return choices.first.dig("message", "content") if choices.is_a?(Array) && choices.first
59
-
60
- raise AIResponseError, "AI response missing choices.message.content"
61
- end
62
-
63
- def validate_configuration!
64
- if @configuration.api_key.to_s.strip.empty?
65
- raise AIResponseError, "Missing API key. Set AIRecordFinder.configure { |c| c.api_key = ... }"
66
- end
18
+ @provider.chat_completion(system_prompt: system_prompt, user_prompt: user_prompt)
67
19
  end
68
20
  end
69
21
  end
@@ -3,24 +3,68 @@
3
3
  module AIRecordFinder
4
4
  # Runtime configuration for AIRecordFinder.
5
5
  class Configuration
6
- DEFAULT_MODEL_NAME = "gpt-4o-mini"
7
- DEFAULT_API_BASE_URL = "https://api.openai.com/v1"
6
+ DEFAULT_PROVIDER = :openai
8
7
  DEFAULT_MAX_LIMIT = 100
9
8
  DEFAULT_TIMEOUT = 15
9
+ DEFAULT_MAX_TOKENS = 1024
10
+ DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
10
11
 
11
- attr_accessor :api_key, :model_name, :max_limit, :allowed_models,
12
- :api_base_url, :request_timeout, :temperature,
13
- :allowed_associations
12
+ # Per-provider defaults for endpoint and model. Used only when the
13
+ # corresponding setting is not explicitly assigned.
14
+ PROVIDER_DEFAULTS = {
15
+ openai: {
16
+ api_base_url: "https://api.openai.com/v1",
17
+ model_name: "gpt-4o-mini"
18
+ },
19
+ anthropic: {
20
+ api_base_url: "https://api.anthropic.com/v1",
21
+ model_name: "claude-sonnet-4-6"
22
+ }
23
+ }.freeze
24
+
25
+ # Backwards-compatible constants (OpenAI defaults).
26
+ DEFAULT_MODEL_NAME = PROVIDER_DEFAULTS[:openai][:model_name]
27
+ DEFAULT_API_BASE_URL = PROVIDER_DEFAULTS[:openai][:api_base_url]
28
+
29
+ # `max_tokens` and `anthropic_version` are consumed only by the Anthropic
30
+ # adapter; the OpenAI adapter ignores them.
31
+ attr_accessor :api_key, :max_limit, :allowed_models, :request_timeout,
32
+ :temperature, :allowed_associations, :max_tokens,
33
+ :anthropic_version
34
+ attr_writer :provider, :model_name, :api_base_url
14
35
 
15
36
  def initialize
16
- @api_key = nil
17
- @model_name = DEFAULT_MODEL_NAME
37
+ @api_key = @model_name = @api_base_url = nil
38
+ @provider = DEFAULT_PROVIDER
18
39
  @max_limit = DEFAULT_MAX_LIMIT
19
40
  @allowed_models = []
20
- @api_base_url = DEFAULT_API_BASE_URL
21
41
  @request_timeout = DEFAULT_TIMEOUT
22
42
  @temperature = 0.0
23
43
  @allowed_associations = {}
44
+ @max_tokens = DEFAULT_MAX_TOKENS
45
+ @anthropic_version = DEFAULT_ANTHROPIC_VERSION
46
+ end
47
+
48
+ # Normalized provider symbol (e.g. :openai, :anthropic).
49
+ def provider
50
+ @provider.to_s.strip.downcase.to_sym
51
+ end
52
+
53
+ # Explicit model name, or the provider default when unset.
54
+ def model_name
55
+ @model_name || provider_default(:model_name)
56
+ end
57
+
58
+ # Explicit base URL, or the provider default when unset.
59
+ def api_base_url
60
+ @api_base_url || provider_default(:api_base_url)
61
+ end
62
+
63
+ private
64
+
65
+ def provider_default(key)
66
+ defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS[DEFAULT_PROVIDER]
67
+ defaults.fetch(key)
24
68
  end
25
69
  end
26
70
  end
@@ -15,4 +15,7 @@ module AIRecordFinder
15
15
 
16
16
  # Raised when a model is not explicitly whitelisted.
17
17
  class UnauthorizedModel < Error; end
18
+
19
+ # Raised when the gem is misconfigured (missing API key, unknown provider, ...).
20
+ class ConfigurationError < Error; end
18
21
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AIRecordFinder
6
+ module Providers
7
+ # Anthropic native Messages API (https://api.anthropic.com/v1/messages).
8
+ #
9
+ # Differs from OpenAI: x-api-key auth + anthropic-version header, a
10
+ # top-level `system` prompt, a required `max_tokens`, and a response whose
11
+ # text lives in a `content` array of typed blocks rather than `choices`.
12
+ class Anthropic < Base
13
+ ENDPOINT_PATH = "messages"
14
+
15
+ private
16
+
17
+ def endpoint_path
18
+ ENDPOINT_PATH
19
+ end
20
+
21
+ def request_headers
22
+ {
23
+ "x-api-key" => configuration.api_key,
24
+ "anthropic-version" => configuration.anthropic_version,
25
+ "Content-Type" => "application/json"
26
+ }
27
+ end
28
+
29
+ def request_payload(system_prompt, user_prompt)
30
+ {
31
+ model: configuration.model_name,
32
+ max_tokens: configuration.max_tokens,
33
+ temperature: configuration.temperature,
34
+ system: system_prompt,
35
+ messages: [
36
+ { role: "user", content: user_prompt }
37
+ ]
38
+ }
39
+ end
40
+
41
+ def extract_content(parsed)
42
+ blocks = parsed["content"]
43
+ if blocks.is_a?(Array)
44
+ text_block = blocks.find { |block| block.is_a?(Hash) && block["type"] == "text" }
45
+ return text_block["text"] if text_block && !text_block["text"].nil?
46
+ end
47
+
48
+ raise AIResponseError, "AI response missing content text block"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module AIRecordFinder
7
+ module Providers
8
+ # Shared HTTP transport for chat-style LLM providers.
9
+ #
10
+ # Subclasses implement the provider-specific contract:
11
+ # #endpoint_path, #request_headers, #request_payload, #extract_content
12
+ #
13
+ # Endpoint paths MUST be relative (no leading slash) so they append to the
14
+ # configured base URL's path (e.g. ".../v1") instead of replacing it.
15
+ class Base
16
+ def initialize(configuration:, connection: nil)
17
+ @configuration = configuration
18
+ @connection = connection
19
+ validate_configuration!
20
+ end
21
+
22
+ def chat_completion(system_prompt:, user_prompt:)
23
+ response = post_request(system_prompt, user_prompt)
24
+ ensure_success!(response)
25
+ extract_content(parse_body(response))
26
+ rescue Faraday::Error => e
27
+ raise AIResponseError, "AI request failed: #{e.message}"
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :configuration
33
+
34
+ def post_request(system_prompt, user_prompt)
35
+ connection.post(endpoint_path) do |req|
36
+ request_headers.each { |name, value| req.headers[name] = value }
37
+ req.body = JSON.generate(request_payload(system_prompt, user_prompt))
38
+ end
39
+ end
40
+
41
+ def connection
42
+ @connection ||= Faraday.new(url: configuration.api_base_url) do |f|
43
+ f.options.timeout = configuration.request_timeout
44
+ f.options.open_timeout = configuration.request_timeout
45
+ f.adapter Faraday.default_adapter
46
+ end
47
+ end
48
+
49
+ def parse_body(response)
50
+ JSON.parse(response.body.to_s)
51
+ rescue JSON::ParserError
52
+ raise AIResponseError, "AI response body is not valid JSON"
53
+ end
54
+
55
+ # Faraday does not raise on non-2xx, so check the status explicitly. Error
56
+ # bodies are not guaranteed to be JSON (a gateway may return HTML/plain
57
+ # text), so this must surface a useful detail without depending on a
58
+ # successful parse — hence it runs before parse_body.
59
+ def ensure_success!(response)
60
+ return if response.success?
61
+
62
+ raise AIResponseError, "AI request failed: #{error_detail(response)}"
63
+ end
64
+
65
+ # Both OpenAI and Anthropic nest the human-readable error under
66
+ # error.message; fall back to the HTTP status for non-JSON error bodies.
67
+ def error_detail(response)
68
+ parsed = JSON.parse(response.body.to_s)
69
+ (parsed.is_a?(Hash) && parsed.dig("error", "message")) || "HTTP #{response.status}"
70
+ rescue JSON::ParserError
71
+ "HTTP #{response.status}"
72
+ end
73
+
74
+ def validate_configuration!
75
+ return unless configuration.api_key.to_s.strip.empty?
76
+
77
+ raise ConfigurationError, "Missing API key. Set AIRecordFinder.configure { |c| c.api_key = ... }"
78
+ end
79
+
80
+ # --- Provider contract (subclasses must override) ---
81
+
82
+ def endpoint_path
83
+ raise NotImplementedError, "#{self.class} must implement #endpoint_path"
84
+ end
85
+
86
+ def request_headers
87
+ raise NotImplementedError, "#{self.class} must implement #request_headers"
88
+ end
89
+
90
+ def request_payload(_system_prompt, _user_prompt)
91
+ raise NotImplementedError, "#{self.class} must implement #request_payload"
92
+ end
93
+
94
+ def extract_content(_parsed)
95
+ raise NotImplementedError, "#{self.class} must implement #extract_content"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module AIRecordFinder
6
+ module Providers
7
+ # OpenAI (and OpenAI-compatible) Chat Completions API.
8
+ class OpenAI < Base
9
+ ENDPOINT_PATH = "chat/completions"
10
+
11
+ private
12
+
13
+ def endpoint_path
14
+ ENDPOINT_PATH
15
+ end
16
+
17
+ def request_headers
18
+ {
19
+ "Authorization" => "Bearer #{configuration.api_key}",
20
+ "Content-Type" => "application/json"
21
+ }
22
+ end
23
+
24
+ def request_payload(system_prompt, user_prompt)
25
+ {
26
+ model: configuration.model_name,
27
+ temperature: configuration.temperature,
28
+ messages: [
29
+ { role: "system", content: system_prompt },
30
+ { role: "user", content: user_prompt }
31
+ ]
32
+ }
33
+ end
34
+
35
+ def extract_content(parsed)
36
+ choices = parsed["choices"]
37
+ if choices.is_a?(Array) && choices.first
38
+ # content is null when the model replies with tool_calls only.
39
+ content = choices.first.dig("message", "content")
40
+ return content unless content.nil?
41
+ end
42
+
43
+ raise AIResponseError, "AI response missing choices[0].message.content"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "providers/base"
4
+ require_relative "providers/openai"
5
+ require_relative "providers/anthropic"
6
+
7
+ module AIRecordFinder
8
+ # Provider strategy registry. Maps a configured provider symbol to the
9
+ # concrete transport that speaks that vendor's chat API.
10
+ module Providers
11
+ REGISTRY = {
12
+ openai: OpenAI,
13
+ anthropic: Anthropic
14
+ }.freeze
15
+
16
+ # @param configuration [AIRecordFinder::Configuration]
17
+ # @param connection [Faraday::Connection, nil] injectable transport (tests)
18
+ # @return [AIRecordFinder::Providers::Base]
19
+ def self.build(configuration:, connection: nil)
20
+ provider_class = REGISTRY[configuration.provider]
21
+ unless provider_class
22
+ raise ConfigurationError,
23
+ "Unknown provider #{configuration.provider.inspect}. " \
24
+ "Supported providers: #{REGISTRY.keys.join(", ")}"
25
+ end
26
+
27
+ provider_class.new(configuration: configuration, connection: connection)
28
+ end
29
+
30
+ def self.supported
31
+ REGISTRY.keys
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AIRecordFinder
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "ai_record_finder/version"
4
4
  require_relative "ai_record_finder/errors"
5
5
  require_relative "ai_record_finder/configuration"
6
+ require_relative "ai_record_finder/providers"
6
7
  require_relative "ai_record_finder/client"
7
8
  require_relative "ai_record_finder/schema_introspector"
8
9
  require_relative "ai_record_finder/prompt_builder"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "json"
5
+
6
+ RSpec.describe AIRecordFinder::Client do
7
+ def stub_connection(base_url, expected_path, response)
8
+ stubs = Faraday::Adapter::Test::Stubs.new
9
+ stubs.post(expected_path) do |_env|
10
+ [200, { "Content-Type" => "application/json" }, JSON.generate(response)]
11
+ end
12
+ Faraday.new(url: base_url) { |f| f.adapter :test, stubs }
13
+ end
14
+
15
+ it "delegates to the OpenAI provider by default" do
16
+ cfg = AIRecordFinder::Configuration.new.tap { |c| c.api_key = "k" }
17
+ conn = stub_connection(
18
+ cfg.api_base_url, "/v1/chat/completions",
19
+ { "choices" => [{ "message" => { "content" => "openai-out" } }] }
20
+ )
21
+
22
+ client = described_class.new(configuration: cfg, connection: conn)
23
+
24
+ expect(client.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("openai-out")
25
+ end
26
+
27
+ it "delegates to the Anthropic provider when configured" do
28
+ cfg = AIRecordFinder::Configuration.new.tap do |c|
29
+ c.api_key = "k"
30
+ c.provider = :anthropic
31
+ end
32
+ conn = stub_connection(
33
+ cfg.api_base_url, "/v1/messages",
34
+ { "content" => [{ "type" => "text", "text" => "anthropic-out" }] }
35
+ )
36
+
37
+ client = described_class.new(configuration: cfg, connection: conn)
38
+
39
+ expect(client.chat_completion(system_prompt: "s", user_prompt: "u")).to eq("anthropic-out")
40
+ end
41
+
42
+ it "raises ConfigurationError when the API key is missing" do
43
+ cfg = AIRecordFinder::Configuration.new
44
+
45
+ expect { described_class.new(configuration: cfg) }
46
+ .to raise_error(AIRecordFinder::ConfigurationError, /Missing API key/)
47
+ end
48
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe AIRecordFinder::Configuration do
6
+ subject(:config) { described_class.new }
7
+
8
+ it "defaults to the OpenAI provider with its model and base URL" do
9
+ expect(config.provider).to eq(:openai)
10
+ expect(config.model_name).to eq("gpt-4o-mini")
11
+ expect(config.api_base_url).to eq("https://api.openai.com/v1")
12
+ end
13
+
14
+ it "resolves Anthropic defaults when the provider is :anthropic" do
15
+ config.provider = :anthropic
16
+
17
+ expect(config.model_name).to eq("claude-sonnet-4-6")
18
+ expect(config.api_base_url).to eq("https://api.anthropic.com/v1")
19
+ end
20
+
21
+ it "normalizes provider strings and casing to a symbol" do
22
+ config.provider = "Anthropic"
23
+
24
+ expect(config.provider).to eq(:anthropic)
25
+ end
26
+
27
+ it "prefers explicitly assigned model_name and api_base_url over provider defaults" do
28
+ config.provider = :anthropic
29
+ config.model_name = "claude-opus-4-8"
30
+ config.api_base_url = "https://gateway.internal/v1"
31
+
32
+ expect(config.model_name).to eq("claude-opus-4-8")
33
+ expect(config.api_base_url).to eq("https://gateway.internal/v1")
34
+ end
35
+
36
+ it "falls back to OpenAI defaults for an unrecognized provider" do
37
+ config.provider = :unknown
38
+
39
+ expect(config.model_name).to eq("gpt-4o-mini")
40
+ expect(config.api_base_url).to eq("https://api.openai.com/v1")
41
+ end
42
+
43
+ it "exposes Anthropic-specific defaults" do
44
+ expect(config.max_tokens).to eq(1024)
45
+ expect(config.anthropic_version).to eq("2023-06-01")
46
+ end
47
+ end