ruby-spacy 0.4.0 → 0.5.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +133 -4
  4. data/Rakefile +13 -0
  5. data/examples/get_started/lexeme.rb +0 -0
  6. data/examples/get_started/linguistic_annotations.rb +0 -0
  7. data/examples/get_started/morphology.rb +0 -0
  8. data/examples/get_started/most_similar.rb +0 -0
  9. data/examples/get_started/named_entities.rb +0 -0
  10. data/examples/get_started/outputs/test_dep.svg +0 -0
  11. data/examples/get_started/outputs/test_dep_compact.svg +0 -0
  12. data/examples/get_started/outputs/test_ent.html +0 -0
  13. data/examples/get_started/pos_tags_and_dependencies.rb +0 -0
  14. data/examples/get_started/similarity.rb +0 -0
  15. data/examples/get_started/tokenization.rb +0 -0
  16. data/examples/get_started/visualizing_dependencies.rb +0 -0
  17. data/examples/get_started/visualizing_dependencies_compact.rb +0 -0
  18. data/examples/get_started/visualizing_named_entities.rb +0 -0
  19. data/examples/get_started/vocab.rb +0 -0
  20. data/examples/get_started/word_vectors.rb +0 -0
  21. data/examples/japanese/ancestors.rb +0 -0
  22. data/examples/japanese/entity_annotations_and_labels.rb +0 -0
  23. data/examples/japanese/information_extraction.rb +0 -0
  24. data/examples/japanese/lemmatization.rb +0 -0
  25. data/examples/japanese/most_similar.rb +0 -0
  26. data/examples/japanese/named_entity_recognition.rb +0 -0
  27. data/examples/japanese/navigating_parse_tree.rb +0 -0
  28. data/examples/japanese/noun_chunks.rb +0 -0
  29. data/examples/japanese/outputs/test_dep.svg +0 -0
  30. data/examples/japanese/outputs/test_ent.html +0 -0
  31. data/examples/japanese/pos_tagging.rb +0 -0
  32. data/examples/japanese/sentence_segmentation.rb +0 -0
  33. data/examples/japanese/similarity.rb +0 -0
  34. data/examples/japanese/tokenization.rb +0 -0
  35. data/examples/japanese/visualizing_dependencies.rb +0 -0
  36. data/examples/japanese/visualizing_named_entities.rb +0 -0
  37. data/examples/linguistic_features/ancestors.rb +0 -0
  38. data/examples/linguistic_features/entity_annotations_and_labels.rb +0 -0
  39. data/examples/linguistic_features/finding_a_verb_with_a_subject.rb +0 -0
  40. data/examples/linguistic_features/information_extraction.rb +0 -0
  41. data/examples/linguistic_features/iterating_children.rb +0 -0
  42. data/examples/linguistic_features/iterating_lefts_and_rights.rb +0 -0
  43. data/examples/linguistic_features/lemmatization.rb +0 -0
  44. data/examples/linguistic_features/named_entity_recognition.rb +0 -0
  45. data/examples/linguistic_features/navigating_parse_tree.rb +0 -0
  46. data/examples/linguistic_features/noun_chunks.rb +0 -0
  47. data/examples/linguistic_features/outputs/test_ent.html +0 -0
  48. data/examples/linguistic_features/pos_tagging.rb +0 -0
  49. data/examples/linguistic_features/retokenize_1.rb +0 -0
  50. data/examples/linguistic_features/retokenize_2.rb +0 -0
  51. data/examples/linguistic_features/rule_based_morphology.rb +0 -0
  52. data/examples/linguistic_features/sentence_segmentation.rb +0 -0
  53. data/examples/linguistic_features/similarity.rb +0 -0
  54. data/examples/linguistic_features/similarity_between_lexemes.rb +0 -0
  55. data/examples/linguistic_features/similarity_between_spans.rb +0 -0
  56. data/examples/linguistic_features/tokenization.rb +0 -0
  57. data/examples/llm/anthropic_chat.rb +21 -0
  58. data/examples/llm/local_llm_ollama.rb +27 -0
  59. data/examples/llm/structured_ner_comparison.rb +49 -0
  60. data/examples/openai_integration/openai_embeddings.rb +0 -0
  61. data/examples/rule_based_matching/creating_spans_from_matches.rb +0 -0
  62. data/examples/rule_based_matching/matcher.rb +0 -0
  63. data/lib/ruby-spacy/anthropic_client.rb +66 -0
  64. data/lib/ruby-spacy/anthropic_helper.rb +118 -0
  65. data/lib/ruby-spacy/llm_client_base.rb +120 -0
  66. data/lib/ruby-spacy/openai_client.rb +37 -107
  67. data/lib/ruby-spacy/openai_helper.rb +60 -16
  68. data/lib/ruby-spacy/version.rb +1 -1
  69. data/lib/ruby-spacy.rb +62 -14
  70. metadata +7 -1
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spacy
4
+ # A helper class for Anthropic (Claude) API interactions, designed to work
5
+ # with spaCy's linguistic analysis via the block-based {Language#with_llm} API.
6
+ #
7
+ # Provides the same chat interface as {OpenAIHelper}, so code written against
8
+ # one provider works with the other. Anthropic does not offer an embeddings
9
+ # API; use the :openai or :ollama provider for embeddings.
10
+ #
11
+ # @example Basic usage with linguistic_summary
12
+ # nlp = Spacy::Language.new("en_core_web_sm")
13
+ # nlp.with_llm(provider: :anthropic) do |ai|
14
+ # doc = nlp.read("Apple Inc. was founded by Steve Jobs.")
15
+ # ai.chat(system: "Analyze the linguistic data.", user: doc.linguistic_summary)
16
+ # end
17
+ class AnthropicHelper
18
+ # @return [String] the default model for chat requests
19
+ attr_reader :model
20
+
21
+ # Creates a new AnthropicHelper instance.
22
+ # @param access_token [String, nil] Anthropic API key (defaults to ANTHROPIC_API_KEY env var)
23
+ # @param model [String] the default model for chat requests
24
+ # @param max_tokens [Integer] default maximum tokens in responses
25
+ # @param temperature [Float, nil] default sampling temperature (omitted from
26
+ # requests when nil; models that reject it are retried without it)
27
+ # @param base_url [String, nil] API endpoint override
28
+ def initialize(access_token: nil, model: AnthropicClient::DEFAULT_MODEL,
29
+ max_tokens: 1000, temperature: nil, base_url: nil)
30
+ @access_token = access_token || ENV["ANTHROPIC_API_KEY"]
31
+ raise "Error: ANTHROPIC_API_KEY is not set" unless @access_token
32
+
33
+ @model = model
34
+ @default_max_tokens = max_tokens
35
+ @default_temperature = temperature
36
+ @client = AnthropicClient.new(access_token: @access_token,
37
+ base_url: base_url || AnthropicClient::API_ENDPOINT)
38
+ end
39
+
40
+ # Sends a Messages API request.
41
+ #
42
+ # Provides convenient `system:` and `user:` keyword arguments as shortcuts.
43
+ # For multi-turn conversations, pass a full `messages:` array directly
44
+ # (the system prompt stays a separate `system:` argument — Anthropic takes
45
+ # it as a top-level parameter, not a message role).
46
+ #
47
+ # @param system [String, nil] system prompt
48
+ # @param user [String, nil] user message content (shortcut)
49
+ # @param messages [Array<Hash>, nil] full message array (overrides user:)
50
+ # @param model [String, nil] model override (defaults to instance model)
51
+ # @param max_tokens [Integer, nil] token limit override
52
+ # @param temperature [Float, nil] temperature override
53
+ # @param schema [Hash, nil] JSON Schema for structured outputs; when given,
54
+ # the model output is constrained to the schema and the parsed Hash is
55
+ # returned. Objects in the schema must set `additionalProperties: false`.
56
+ # @param raw [Boolean] if true, returns the full API response Hash instead of text
57
+ # @return [String, Hash, nil] the response text, parsed Hash (if schema:),
58
+ # full response Hash (if raw:), or nil on API error, refusal, or when
59
+ # the schema output cannot be parsed as JSON (e.g., truncated by the
60
+ # token limit)
61
+ def chat(system: nil, user: nil, messages: nil,
62
+ model: nil, max_tokens: nil, temperature: nil,
63
+ schema: nil, raw: false)
64
+ msgs = messages || (user ? [{ role: "user", content: user }] : [])
65
+ raise ArgumentError, "No messages provided. Use user:/messages:" if msgs.empty?
66
+
67
+ output_config = schema ? { format: { type: "json_schema", schema: schema } } : nil
68
+
69
+ response = @client.messages(
70
+ model: model || @model,
71
+ messages: msgs,
72
+ system: system,
73
+ max_tokens: max_tokens || @default_max_tokens,
74
+ temperature: temperature || @default_temperature,
75
+ output_config: output_config
76
+ )
77
+
78
+ return response if raw
79
+
80
+ # Safety classifiers can decline a request with HTTP 200 and an empty
81
+ # or partial content array.
82
+ if response["stop_reason"] == "refusal"
83
+ warn "Error: Anthropic API declined the request (stop_reason: refusal)"
84
+ return nil
85
+ end
86
+
87
+ if response["stop_reason"] == "max_tokens"
88
+ warn "Warning: response was truncated (stop_reason: max_tokens); consider increasing max_tokens"
89
+ end
90
+
91
+ text = Array(response["content"])
92
+ .select { |block| block["type"] == "text" }
93
+ .map { |block| block["text"] }
94
+ .join
95
+ schema ? parse_json_content(text) : text
96
+ rescue AnthropicClient::APIError => e
97
+ warn "Error: Anthropic API call failed - #{e.message}"
98
+ nil
99
+ end
100
+
101
+ # Anthropic does not provide an embeddings API.
102
+ # @raise [NotImplementedError] always
103
+ def embeddings(*)
104
+ raise NotImplementedError,
105
+ "Anthropic does not provide an embeddings API. " \
106
+ "Use with_llm(provider: :openai) or a local OpenAI-compatible server for embeddings."
107
+ end
108
+
109
+ private
110
+
111
+ def parse_json_content(text)
112
+ JSON.parse(text)
113
+ rescue JSON::ParserError
114
+ warn "Error: response is not valid JSON (possibly truncated output); returning nil"
115
+ nil
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module Spacy
9
+ # Shared HTTP layer for LLM API clients ({OpenAIClient}, {AnthropicClient}).
10
+ # Implements JSON POST requests with retry on rate limits (429/529) and
11
+ # transient network errors, using only net/http (no external dependencies).
12
+ class LLMClientBase
13
+ # Default request timeout in seconds
14
+ DEFAULT_TIMEOUT = 120
15
+ # Maximum number of retries for rate-limited requests and network errors
16
+ MAX_RETRIES = 3
17
+ # Base delay in seconds for exponential backoff between retries
18
+ BASE_RETRY_DELAY = 1
19
+
20
+ # Raised when an LLM API request fails after retries; carries the HTTP
21
+ # status code and the parsed response body when available.
22
+ class APIError < StandardError
23
+ attr_reader :status_code, :response_body
24
+
25
+ def initialize(message, status_code: nil, response_body: nil)
26
+ @status_code = status_code
27
+ @response_body = response_body
28
+ super(message)
29
+ end
30
+ end
31
+
32
+ def initialize(base_url:, timeout: DEFAULT_TIMEOUT)
33
+ @base_url = base_url.sub(%r{/\z}, "")
34
+ @timeout = timeout
35
+ end
36
+
37
+ private
38
+
39
+ # Subclasses provide authentication and API-specific headers.
40
+ # @return [Hash{String => String}]
41
+ def request_headers
42
+ {}
43
+ end
44
+
45
+ # Creates a certificate store with system CA certificates but without CRL checking.
46
+ # This avoids "unable to get certificate CRL" errors on some systems.
47
+ def default_cert_store
48
+ store = OpenSSL::X509::Store.new
49
+ store.set_default_paths
50
+ store
51
+ end
52
+
53
+ def post(path, body)
54
+ uri = URI.parse("#{@base_url}#{path}")
55
+ retries = 0
56
+
57
+ loop do
58
+ begin
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ if uri.scheme == "https"
61
+ http.use_ssl = true
62
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
63
+ http.cert_store = default_cert_store
64
+ end
65
+ http.open_timeout = @timeout
66
+ http.read_timeout = @timeout
67
+
68
+ request = Net::HTTP::Post.new(uri.request_uri)
69
+ request["Content-Type"] = "application/json"
70
+ request_headers.each { |key, value| request[key] = value }
71
+ request.body = body.to_json
72
+
73
+ response = http.request(request)
74
+
75
+ # Handle rate limiting (429) and overload (529) before general response handling
76
+ if [429, 529].include?(response.code.to_i)
77
+ retries += 1
78
+ if retries <= MAX_RETRIES
79
+ retry_after = response["Retry-After"]&.to_f
80
+ delay = retry_after || (BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5)
81
+ sleep delay
82
+ next
83
+ end
84
+ raise APIError.new("Rate limited after #{MAX_RETRIES} retries",
85
+ status_code: response.code.to_i, response_body: response.body)
86
+ end
87
+
88
+ return handle_response(response)
89
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
90
+ retries += 1
91
+ if retries <= MAX_RETRIES
92
+ delay = BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5
93
+ sleep delay
94
+ next
95
+ end
96
+ raise APIError.new("Network error after #{MAX_RETRIES} retries: #{e.message}")
97
+ end
98
+ end
99
+ end
100
+
101
+ def handle_response(response)
102
+ body = JSON.parse(response.body)
103
+
104
+ case response.code.to_i
105
+ when 200
106
+ body
107
+ when 400..499
108
+ error_message = body.dig("error", "message") || "Client error"
109
+ raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
110
+ when 500..599
111
+ error_message = body.dig("error", "message") || "Server error"
112
+ raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
113
+ else
114
+ raise APIError.new("Unexpected response: #{response.code}", status_code: response.code.to_i, response_body: body)
115
+ end
116
+ rescue JSON::ParserError
117
+ raise APIError.new("Invalid JSON response", status_code: response.code.to_i, response_body: response.body)
118
+ end
119
+ end
120
+ end
@@ -1,42 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "openssl"
5
- require "uri"
6
- require "json"
3
+ require_relative "llm_client_base"
7
4
 
8
5
  module Spacy
9
- # A lightweight OpenAI API client with tools support for GPT-5 series models.
6
+ # A lightweight OpenAI API client with tools support.
10
7
  # This client implements the chat completions and embeddings endpoints
11
8
  # without external dependencies.
12
- class OpenAIClient
9
+ #
10
+ # A custom +base_url+ makes it work with any OpenAI-compatible server
11
+ # (Ollama, LM Studio, llama.cpp server, vLLM, OpenRouter, etc.).
12
+ class OpenAIClient < LLMClientBase
13
+ # Default OpenAI API endpoint
13
14
  API_ENDPOINT = "https://api.openai.com/v1"
14
- DEFAULT_TIMEOUT = 120
15
- MAX_RETRIES = 3
16
- BASE_RETRY_DELAY = 1
17
-
18
- class APIError < StandardError
19
- attr_reader :status_code, :response_body
20
-
21
- def initialize(message, status_code: nil, response_body: nil)
22
- @status_code = status_code
23
- @response_body = response_body
24
- super(message)
25
- end
26
- end
27
-
28
- def initialize(access_token:, timeout: DEFAULT_TIMEOUT)
15
+ # Default model for chat requests
16
+ DEFAULT_MODEL = "gpt-5-mini"
17
+ # Default model for embeddings requests
18
+ DEFAULT_EMBEDDINGS_MODEL = "text-embedding-3-small"
19
+ # (see LLMClientBase::APIError)
20
+ APIError = LLMClientBase::APIError
21
+
22
+ # @param access_token [String] API key ("ollama" or any placeholder for local servers)
23
+ # @param timeout [Integer] request timeout in seconds
24
+ # @param base_url [String] API endpoint; include the version path
25
+ # (e.g., "http://localhost:11434/v1" for Ollama)
26
+ def initialize(access_token:, timeout: DEFAULT_TIMEOUT, base_url: API_ENDPOINT)
27
+ super(base_url: base_url, timeout: timeout)
29
28
  @access_token = access_token
30
- @timeout = timeout
31
29
  end
32
30
 
33
31
  # Sends a chat completion request with optional tools support.
34
- # Note: GPT-5 series and o-series models do not support the temperature parameter.
32
+ #
33
+ # The +temperature+ parameter is sent only when explicitly given. If the
34
+ # model rejects it (e.g., GPT-5 series and o-series models), the request
35
+ # is retried once without it.
35
36
  #
36
37
  # @param model [String] The model to use (e.g., "gpt-5-mini")
37
38
  # @param messages [Array<Hash>] The conversation messages
38
39
  # @param max_completion_tokens [Integer] Maximum tokens in the response
39
- # @param temperature [Float, nil] Sampling temperature (ignored for models that don't support it)
40
+ # @param temperature [Float, nil] Sampling temperature (omitted when nil)
40
41
  # @param tools [Array<Hash>, nil] Tool definitions for function calling
41
42
  # @param tool_choice [String, Hash, nil] Tool selection strategy
42
43
  # @param response_format [Hash, nil] Response format specification (e.g., { type: "json_object" })
@@ -47,11 +48,7 @@ module Spacy
47
48
  messages: messages,
48
49
  max_completion_tokens: max_completion_tokens
49
50
  }
50
-
51
- # GPT-5 series and o-series models do not support temperature parameter
52
- unless temperature_unsupported?(model)
53
- body[:temperature] = temperature || 0.7
54
- end
51
+ body[:temperature] = temperature unless temperature.nil?
55
52
 
56
53
  if tools && !tools.empty?
57
54
  body[:tools] = tools
@@ -60,16 +57,7 @@ module Spacy
60
57
 
61
58
  body[:response_format] = response_format if response_format
62
59
 
63
- post("/chat/completions", body)
64
- end
65
-
66
- # Checks if the model does not support the temperature parameter.
67
- # This includes GPT-5 series and o-series (o1, o3, o4-mini, etc.) models.
68
- # @param model [String] The model name
69
- # @return [Boolean]
70
- def temperature_unsupported?(model)
71
- name = model.to_s
72
- name.start_with?("gpt-5") || name.match?(/\Ao\d/)
60
+ post_with_temperature_fallback("/chat/completions", body)
73
61
  end
74
62
 
75
63
  # Sends an embeddings request.
@@ -90,77 +78,19 @@ module Spacy
90
78
 
91
79
  private
92
80
 
93
- # Creates a certificate store with system CA certificates but without CRL checking.
94
- # This avoids "unable to get certificate CRL" errors on some systems.
95
- def default_cert_store
96
- store = OpenSSL::X509::Store.new
97
- store.set_default_paths
98
- store
81
+ def request_headers
82
+ { "Authorization" => "Bearer #{@access_token}" }
99
83
  end
100
84
 
101
- def post(path, body)
102
- uri = URI.parse("#{API_ENDPOINT}#{path}")
103
- retries = 0
85
+ # Models that do not accept the temperature parameter reject it with a 400;
86
+ # retry once without it so callers need no per-model knowledge.
87
+ def post_with_temperature_fallback(path, body)
88
+ post(path, body)
89
+ rescue APIError => e
90
+ raise unless body.key?(:temperature) && e.status_code == 400 && e.message.match?(/temperature/i)
104
91
 
105
- loop do
106
- begin
107
- http = Net::HTTP.new(uri.host, uri.port)
108
- http.use_ssl = true
109
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
110
- http.cert_store = default_cert_store
111
- http.open_timeout = @timeout
112
- http.read_timeout = @timeout
113
-
114
- request = Net::HTTP::Post.new(uri.path)
115
- request["Content-Type"] = "application/json"
116
- request["Authorization"] = "Bearer #{@access_token}"
117
- request.body = body.to_json
118
-
119
- response = http.request(request)
120
-
121
- # Handle 429 rate limiting before general response handling
122
- if response.code.to_i == 429
123
- retries += 1
124
- if retries <= MAX_RETRIES
125
- retry_after = response["Retry-After"]&.to_f
126
- delay = retry_after || (BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5)
127
- sleep delay
128
- next
129
- end
130
- raise APIError.new("Rate limited after #{MAX_RETRIES} retries",
131
- status_code: 429, response_body: response.body)
132
- end
133
-
134
- return handle_response(response)
135
- rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
136
- retries += 1
137
- if retries <= MAX_RETRIES
138
- delay = BASE_RETRY_DELAY * (2**(retries - 1)) + rand * 0.5
139
- sleep delay
140
- next
141
- end
142
- raise APIError.new("Network error after #{MAX_RETRIES} retries: #{e.message}")
143
- end
144
- end
145
- end
146
-
147
- def handle_response(response)
148
- body = JSON.parse(response.body)
149
-
150
- case response.code.to_i
151
- when 200
152
- body
153
- when 400..499
154
- error_message = body.dig("error", "message") || "Client error"
155
- raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
156
- when 500..599
157
- error_message = body.dig("error", "message") || "Server error"
158
- raise APIError.new(error_message, status_code: response.code.to_i, response_body: body)
159
- else
160
- raise APIError.new("Unexpected response: #{response.code}", status_code: response.code.to_i, response_body: body)
161
- end
162
- rescue JSON::ParserError
163
- raise APIError.new("Invalid JSON response", status_code: response.code.to_i, response_body: response.body)
92
+ warn "Warning: model does not support the temperature parameter; retrying without it"
93
+ post(path, body.reject { |key, _| key == :temperature })
164
94
  end
165
95
  end
166
96
  end
@@ -2,7 +2,11 @@
2
2
 
3
3
  module Spacy
4
4
  # A helper class for OpenAI API interactions, designed to work with spaCy's
5
- # linguistic analysis via the block-based {Language#with_openai} API.
5
+ # linguistic analysis via the block-based {Language#with_llm} /
6
+ # {Language#with_openai} API.
7
+ #
8
+ # With a custom +base_url+, this helper also works with any OpenAI-compatible
9
+ # server such as Ollama, LM Studio, llama.cpp server, or vLLM.
6
10
  #
7
11
  # @example Basic usage with linguistic_summary
8
12
  # nlp = Spacy::Language.new("en_core_web_sm")
@@ -10,6 +14,11 @@ module Spacy
10
14
  # doc = nlp.read("Apple Inc. was founded by Steve Jobs.")
11
15
  # ai.chat(system: "Analyze the linguistic data.", user: doc.linguistic_summary)
12
16
  # end
17
+ #
18
+ # @example Local model via Ollama
19
+ # nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
20
+ # ai.chat(user: "Say hello.")
21
+ # end
13
22
  class OpenAIHelper
14
23
  # @return [String] the default model for chat requests
15
24
  attr_reader :model
@@ -18,19 +27,25 @@ module Spacy
18
27
  # @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
19
28
  # @param model [String] the default model for chat requests
20
29
  # @param max_completion_tokens [Integer] default maximum tokens in responses
21
- # @param temperature [Float] default sampling temperature
22
- def initialize(access_token: nil, model: "gpt-5-mini",
23
- max_completion_tokens: 1000, temperature: 0.7)
30
+ # @param max_tokens [Integer, nil] alias for max_completion_tokens (takes precedence)
31
+ # @param temperature [Float, nil] default sampling temperature (omitted from
32
+ # requests when nil; models that reject it are retried without it)
33
+ # @param base_url [String, nil] OpenAI-compatible API endpoint
34
+ # (e.g., "http://localhost:11434/v1" for Ollama)
35
+ def initialize(access_token: nil, model: OpenAIClient::DEFAULT_MODEL,
36
+ max_completion_tokens: 1000, max_tokens: nil,
37
+ temperature: nil, base_url: nil)
24
38
  @access_token = access_token || ENV["OPENAI_API_KEY"]
25
39
  raise "Error: OPENAI_API_KEY is not set" unless @access_token
26
40
 
27
41
  @model = model
28
- @default_max_completion_tokens = max_completion_tokens
42
+ @default_max_completion_tokens = max_tokens || max_completion_tokens
29
43
  @default_temperature = temperature
30
- @client = OpenAIClient.new(access_token: @access_token)
44
+ @client = OpenAIClient.new(access_token: @access_token,
45
+ base_url: base_url || OpenAIClient::API_ENDPOINT)
31
46
  end
32
47
 
33
- # Sends a chat completion request to OpenAI.
48
+ # Sends a chat completion request.
34
49
  #
35
50
  # Provides convenient `system:` and `user:` keyword arguments as shortcuts
36
51
  # for building simple message arrays. For more complex conversations, pass
@@ -41,41 +56,63 @@ module Spacy
41
56
  # @param messages [Array<Hash>, nil] full message array (overrides system:/user:)
42
57
  # @param model [String, nil] model override (defaults to instance model)
43
58
  # @param max_completion_tokens [Integer, nil] token limit override
59
+ # @param max_tokens [Integer, nil] alias for max_completion_tokens (takes precedence)
44
60
  # @param temperature [Float, nil] temperature override
45
61
  # @param response_format [Hash, nil] response format (e.g., { type: "json_object" })
62
+ # @param schema [Hash, nil] JSON Schema for structured outputs; when given,
63
+ # the model output is constrained to the schema and the parsed Hash is
64
+ # returned. Objects in the schema must set `additionalProperties: false`
65
+ # and list all properties in `required`.
46
66
  # @param raw [Boolean] if true, returns the full API response Hash instead of text
47
- # @return [String, Hash, nil] the response text, full response Hash (if raw:), or nil on error
67
+ # @return [String, Hash, nil] the response text, parsed Hash (if schema:),
68
+ # full response Hash (if raw:), or nil on API error or when the schema
69
+ # output cannot be parsed as JSON (e.g., truncated by the token limit)
48
70
  def chat(system: nil, user: nil, messages: nil,
49
- model: nil, max_completion_tokens: nil,
50
- temperature: nil, response_format: nil, raw: false)
71
+ model: nil, max_completion_tokens: nil, max_tokens: nil,
72
+ temperature: nil, response_format: nil, schema: nil, raw: false)
51
73
  msgs = messages || build_messages(system: system, user: user)
52
74
  raise ArgumentError, "No messages provided. Use system:/user: or messages:" if msgs.empty?
53
75
 
76
+ if schema
77
+ response_format = {
78
+ type: "json_schema",
79
+ json_schema: { name: "response", strict: true, schema: schema }
80
+ }
81
+ end
82
+
54
83
  response = @client.chat(
55
84
  model: model || @model,
56
85
  messages: msgs,
57
- max_completion_tokens: max_completion_tokens || @default_max_completion_tokens,
86
+ max_completion_tokens: max_tokens || max_completion_tokens || @default_max_completion_tokens,
58
87
  temperature: temperature || @default_temperature,
59
88
  response_format: response_format
60
89
  )
61
90
 
62
- raw ? response : response.dig("choices", 0, "message", "content")
91
+ return response if raw
92
+
93
+ choice = response.dig("choices", 0)
94
+ if choice&.dig("finish_reason") == "length"
95
+ warn "Warning: response was truncated (finish_reason: length); consider increasing max_completion_tokens"
96
+ end
97
+
98
+ content = choice&.dig("message", "content")
99
+ schema && content ? parse_json_content(content) : content
63
100
  rescue OpenAIClient::APIError => e
64
- puts "Error: OpenAI API call failed - #{e.message}"
101
+ warn "Error: OpenAI API call failed - #{e.message}"
65
102
  nil
66
103
  end
67
104
 
68
- # Generates text embeddings using OpenAI's embeddings API.
105
+ # Generates text embeddings using the embeddings API.
69
106
  #
70
107
  # @param text [String] the text to embed
71
108
  # @param model [String] the embeddings model
72
109
  # @param dimensions [Integer, nil] number of dimensions (nil uses model default)
73
110
  # @return [Array<Float>, nil] the embedding vector, or nil on error
74
- def embeddings(text, model: "text-embedding-3-small", dimensions: nil)
111
+ def embeddings(text, model: OpenAIClient::DEFAULT_EMBEDDINGS_MODEL, dimensions: nil)
75
112
  response = @client.embeddings(model: model, input: text, dimensions: dimensions)
76
113
  response.dig("data", 0, "embedding")
77
114
  rescue OpenAIClient::APIError => e
78
- puts "Error: OpenAI API call failed - #{e.message}"
115
+ warn "Error: OpenAI API call failed - #{e.message}"
79
116
  nil
80
117
  end
81
118
 
@@ -87,5 +124,12 @@ module Spacy
87
124
  msgs << { role: "user", content: user } if user
88
125
  msgs
89
126
  end
127
+
128
+ def parse_json_content(text)
129
+ JSON.parse(text)
130
+ rescue JSON::ParserError
131
+ warn "Error: response is not valid JSON (possibly truncated output); returning nil"
132
+ nil
133
+ end
90
134
  end
91
135
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Spacy
4
4
  # The version number of the module
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end