ruby-spacy 0.4.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +74 -5
- data/examples/llm/anthropic_chat.rb +21 -0
- data/examples/llm/local_llm_ollama.rb +27 -0
- data/examples/llm/structured_ner_comparison.rb +49 -0
- data/lib/ruby-spacy/anthropic_client.rb +66 -0
- data/lib/ruby-spacy/anthropic_helper.rb +118 -0
- data/lib/ruby-spacy/llm_client_base.rb +120 -0
- data/lib/ruby-spacy/openai_client.rb +37 -107
- data/lib/ruby-spacy/openai_helper.rb +60 -16
- data/lib/ruby-spacy/version.rb +1 -1
- data/lib/ruby-spacy.rb +62 -14
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b759c507a1cad8e726bd36ad39e4539894395407e2ac5d64a31b7b907a4e34d8
|
|
4
|
+
data.tar.gz: 0f02f9b8f6bcf39a49b50b1934905e72c0c13bb9652eee8fbaad3a3b7b58b7e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b7d8078148e05c860f3ef698b5103cfca533fabfac5001c5afd12419efaae21d6492aa22fcebfba9d9d22bf8701fe2c6a21eb1ee879344cdbd5d897af9b8853
|
|
7
|
+
data.tar.gz: 4ffab4be8e0338e86c34aad0285cd9471a3d5c570fbf48b4214cdbe5ca5cc01c7d251d4f6d5edc778de2185d89fc3858e094c363499c81f89d5c3a61ab8859c2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-07-21
|
|
4
|
+
### Added
|
|
5
|
+
- `Language#with_llm(provider:)` — provider-neutral block-based LLM API
|
|
6
|
+
supporting `:openai`, `:anthropic` (Claude), and `:ollama` (local models)
|
|
7
|
+
- `AnthropicHelper` / `AnthropicClient` — Anthropic Messages API support
|
|
8
|
+
using only net/http (default model: `claude-sonnet-5`; requires
|
|
9
|
+
`ANTHROPIC_API_KEY`)
|
|
10
|
+
- `base_url:` option for OpenAI client/helper — works with any
|
|
11
|
+
OpenAI-compatible server (Ollama, LM Studio, llama.cpp server, vLLM,
|
|
12
|
+
OpenRouter, etc.)
|
|
13
|
+
- `schema:` option for `chat` — Structured Outputs on both providers;
|
|
14
|
+
returns a validated, parsed Ruby Hash
|
|
15
|
+
- New examples under `examples/llm/` (Anthropic, local Ollama, and a
|
|
16
|
+
spaCy-vs-LLM NER comparison with structured outputs)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `temperature` is now sent only when explicitly specified; if a model
|
|
20
|
+
rejects it, the request is retried once without it. The model-name
|
|
21
|
+
heuristic (`temperature_unsupported?`) has been removed, so new models
|
|
22
|
+
work without gem updates. Note: previously a default of 0.7 was sent to
|
|
23
|
+
models that supported it (also from `Doc#openai_query` /
|
|
24
|
+
`Doc#openai_completion`); now the API default applies unless you pass
|
|
25
|
+
`temperature:` yourself
|
|
26
|
+
- Shared HTTP layer (`LLMClientBase`) extracted from `OpenAIClient`;
|
|
27
|
+
no behavior change for existing OpenAI usage
|
|
28
|
+
- Truncated responses (`finish_reason: length` / `stop_reason: max_tokens`)
|
|
29
|
+
now emit a warning; with `schema:`, unparseable (e.g. truncated) JSON
|
|
30
|
+
returns nil with a warning instead of raising `JSON::ParserError`
|
|
31
|
+
- LLM error messages are printed to stderr (`Kernel#warn`) instead of
|
|
32
|
+
stdout, keeping stdout clean for program output
|
|
33
|
+
- Default model names are now public constants:
|
|
34
|
+
`OpenAIClient::DEFAULT_MODEL`, `OpenAIClient::DEFAULT_EMBEDDINGS_MODEL`,
|
|
35
|
+
`AnthropicClient::DEFAULT_MODEL`
|
|
36
|
+
|
|
3
37
|
## 0.4.1 - 2026-07-19
|
|
4
38
|
### Fixed
|
|
5
39
|
- Gem packaging: normalize file permissions at build time so packaged files
|
data/README.md
CHANGED
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
| ✅ | Named entity recognition |
|
|
12
12
|
| ✅ | Syntactic dependency visualization |
|
|
13
13
|
| ✅ | Access to pre-trained word vectors |
|
|
14
|
-
| ✅ | OpenAI
|
|
14
|
+
| ✅ | LLM integration: OpenAI, Anthropic (Claude), and local models |
|
|
15
15
|
|
|
16
|
-
Current Version: `0.
|
|
16
|
+
Current Version: `0.5.0`
|
|
17
17
|
|
|
18
18
|
- Ruby 4.0 supported
|
|
19
19
|
- spaCy 3.8 supported
|
|
20
|
-
- OpenAI
|
|
21
|
-
-
|
|
20
|
+
- Multi-provider LLM API: OpenAI, Anthropic (Claude), and local models via Ollama or any OpenAI-compatible server
|
|
21
|
+
- Structured outputs (JSON Schema) support
|
|
22
|
+
- Block-based LLM API with linguistic analysis
|
|
22
23
|
|
|
23
24
|
## Installation of Prerequisites
|
|
24
25
|
|
|
@@ -588,7 +589,9 @@ end
|
|
|
588
589
|
|
|
589
590
|
## OpenAI API Integration
|
|
590
591
|
|
|
591
|
-
> ⚠️ This feature requires GPT-5 series models. Please refer to OpenAI's [API reference](https://platform.openai.com/docs/api-reference) for details.
|
|
592
|
+
> ⚠️ This feature requires GPT-5 series models. Please refer to OpenAI's [API reference](https://platform.openai.com/docs/api-reference) for details.
|
|
593
|
+
|
|
594
|
+
> ℹ️ The `temperature` parameter is sent to the API only when you specify it explicitly. If the model does not support it (e.g., GPT-5 series and o-series models), the request is automatically retried once without it — no per-model configuration is needed.
|
|
592
595
|
|
|
593
596
|
Easily leverage GPT models within ruby-spacy by using an OpenAI API key. When constructing prompts for the `Doc::openai_query` method, you can incorporate the following token properties of the document. These properties are retrieved through tool calls (made internally by GPT when necessary) and seamlessly integrated into your prompt. The available properties include:
|
|
594
597
|
|
|
@@ -903,6 +906,72 @@ nlp.with_openai do |ai|
|
|
|
903
906
|
end
|
|
904
907
|
```
|
|
905
908
|
|
|
909
|
+
### Multi-provider LLM API
|
|
910
|
+
|
|
911
|
+
The `Language#with_llm` block API generalizes `with_openai` to multiple providers. The helper yielded to the block has the same `chat` interface for every provider.
|
|
912
|
+
|
|
913
|
+
**Anthropic (Claude):**
|
|
914
|
+
|
|
915
|
+
```ruby
|
|
916
|
+
# Requires the ANTHROPIC_API_KEY environment variable
|
|
917
|
+
# Default model: claude-sonnet-5 (override with model: "...")
|
|
918
|
+
nlp.with_llm(provider: :anthropic) do |ai|
|
|
919
|
+
result = ai.chat(
|
|
920
|
+
system: "You are a linguistic analyst.",
|
|
921
|
+
user: doc.linguistic_summary
|
|
922
|
+
)
|
|
923
|
+
puts result
|
|
924
|
+
end
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
**Local models via Ollama (no API key needed):**
|
|
928
|
+
|
|
929
|
+
```ruby
|
|
930
|
+
# Requires a running Ollama server: https://ollama.com
|
|
931
|
+
nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
932
|
+
puts ai.chat(user: doc.linguistic_summary)
|
|
933
|
+
end
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
Any other OpenAI-compatible server (LM Studio, llama.cpp server, vLLM, OpenRouter, etc.) works via `base_url:`:
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
nlp.with_llm(provider: :openai, base_url: "http://localhost:1234/v1",
|
|
940
|
+
access_token: "not-needed", model: "your-model") do |ai|
|
|
941
|
+
puts ai.chat(user: "Hello!")
|
|
942
|
+
end
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
**Structured outputs (`schema:`):** pass a JSON Schema and receive a validated, parsed Ruby Hash — useful for comparing LLM output with spaCy's analysis programmatically. Works with both `:openai` and `:anthropic`. Objects in the schema must set `additionalProperties: false`.
|
|
946
|
+
|
|
947
|
+
```ruby
|
|
948
|
+
schema = {
|
|
949
|
+
type: "object",
|
|
950
|
+
properties: {
|
|
951
|
+
entities: {
|
|
952
|
+
type: "array",
|
|
953
|
+
items: {
|
|
954
|
+
type: "object",
|
|
955
|
+
properties: { text: { type: "string" }, label: { type: "string" } },
|
|
956
|
+
required: %w[text label],
|
|
957
|
+
additionalProperties: false
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
required: ["entities"],
|
|
962
|
+
additionalProperties: false
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
result = nlp.with_llm(provider: :openai) do |ai|
|
|
966
|
+
ai.chat(system: "Extract named entities.", user: doc.text, schema: schema)
|
|
967
|
+
end
|
|
968
|
+
result["entities"].each { |ent| puts "#{ent["text"]} (#{ent["label"]})" }
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
**Note on `temperature`:** for all providers, `temperature` is omitted from requests unless you pass it explicitly (`ai.chat(user: "...", temperature: 0.3)`). Models that reject the parameter (e.g., GPT-5 series, o-series, and current Claude models) are automatically retried once without it, so any model works without per-model configuration.
|
|
972
|
+
|
|
973
|
+
See `examples/llm/` for complete scripts, including a spaCy-vs-LLM NER comparison.
|
|
974
|
+
|
|
906
975
|
## Advanced Usage
|
|
907
976
|
|
|
908
977
|
### Setting a Timeout
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# add path to ruby-spacy lib to load path
|
|
4
|
+
$LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
|
|
5
|
+
|
|
6
|
+
require "ruby-spacy"
|
|
7
|
+
|
|
8
|
+
# Requires the ANTHROPIC_API_KEY environment variable
|
|
9
|
+
# Default model: claude-sonnet-5 (override with model: "...")
|
|
10
|
+
|
|
11
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
12
|
+
doc = nlp.read("The cat sat on the mat while the dog slept under the table.")
|
|
13
|
+
|
|
14
|
+
result = nlp.with_llm(provider: :anthropic) do |ai|
|
|
15
|
+
ai.chat(
|
|
16
|
+
system: "You are a linguistics tutor. Explain the syntactic structure briefly.",
|
|
17
|
+
user: doc.linguistic_summary
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
puts result
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# add path to ruby-spacy lib to load path
|
|
4
|
+
$LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
|
|
5
|
+
|
|
6
|
+
require "ruby-spacy"
|
|
7
|
+
|
|
8
|
+
# Requires a running Ollama server (https://ollama.com):
|
|
9
|
+
# ollama pull llama3.2
|
|
10
|
+
# ollama serve
|
|
11
|
+
# No API key is needed.
|
|
12
|
+
|
|
13
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
14
|
+
doc = nlp.read("I sat on the bank of the river.")
|
|
15
|
+
|
|
16
|
+
result = nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
17
|
+
ai.chat(
|
|
18
|
+
system: "Identify the meaning of the word 'bank' in one short sentence.",
|
|
19
|
+
user: doc.linguistic_summary
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
puts result
|
|
24
|
+
|
|
25
|
+
# Any OpenAI-compatible server works the same way via base_url, e.g.:
|
|
26
|
+
# nlp.with_llm(provider: :openai, base_url: "http://localhost:1234/v1",
|
|
27
|
+
# access_token: "not-needed", model: "your-model") { |ai| ... }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# add path to ruby-spacy lib to load path
|
|
4
|
+
$LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
|
|
5
|
+
|
|
6
|
+
require "ruby-spacy"
|
|
7
|
+
|
|
8
|
+
# Compares spaCy's named entity recognition with an LLM's, using structured
|
|
9
|
+
# outputs so the LLM's answer comes back as a validated Ruby Hash.
|
|
10
|
+
# Requires the OPENAI_API_KEY environment variable
|
|
11
|
+
# (or swap in provider: :anthropic with ANTHROPIC_API_KEY).
|
|
12
|
+
|
|
13
|
+
nlp = Spacy::Language.new("en_core_web_sm")
|
|
14
|
+
text = "Mr. Best flew to New York on Saturday morning to meet Tim Cook of Apple."
|
|
15
|
+
doc = nlp.read(text)
|
|
16
|
+
|
|
17
|
+
schema = {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
entities: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
text: { type: "string" },
|
|
26
|
+
label: { type: "string", enum: %w[PERSON ORG GPE DATE TIME OTHER] }
|
|
27
|
+
},
|
|
28
|
+
required: %w[text label],
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ["entities"],
|
|
34
|
+
additionalProperties: false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
llm_result = nlp.with_llm(provider: :openai) do |ai|
|
|
38
|
+
ai.chat(
|
|
39
|
+
system: "Extract all named entities from the user's text.",
|
|
40
|
+
user: text,
|
|
41
|
+
schema: schema
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
puts "spaCy entities:"
|
|
46
|
+
doc.ents.each { |ent| puts " #{ent.text} (#{ent.label})" }
|
|
47
|
+
|
|
48
|
+
puts "\nLLM entities:"
|
|
49
|
+
llm_result["entities"].each { |ent| puts " #{ent["text"]} (#{ent["label"]})" }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_client_base"
|
|
4
|
+
|
|
5
|
+
module Spacy
|
|
6
|
+
# A lightweight Anthropic Messages API client without external dependencies.
|
|
7
|
+
class AnthropicClient < LLMClientBase
|
|
8
|
+
# Default Anthropic API endpoint
|
|
9
|
+
API_ENDPOINT = "https://api.anthropic.com/v1"
|
|
10
|
+
# Messages API version header value
|
|
11
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
12
|
+
# Default model for chat requests
|
|
13
|
+
DEFAULT_MODEL = "claude-sonnet-5"
|
|
14
|
+
# (see LLMClientBase::APIError)
|
|
15
|
+
APIError = LLMClientBase::APIError
|
|
16
|
+
|
|
17
|
+
# @param access_token [String] Anthropic API key
|
|
18
|
+
# @param timeout [Integer] request timeout in seconds
|
|
19
|
+
# @param base_url [String] API endpoint override
|
|
20
|
+
def initialize(access_token:, timeout: DEFAULT_TIMEOUT, base_url: API_ENDPOINT)
|
|
21
|
+
super(base_url: base_url, timeout: timeout)
|
|
22
|
+
@access_token = access_token
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Sends a Messages API request.
|
|
26
|
+
#
|
|
27
|
+
# The +temperature+ parameter is sent only when explicitly given. Current
|
|
28
|
+
# Claude models (Opus 4.7+) reject it; the request is then retried once
|
|
29
|
+
# without it.
|
|
30
|
+
#
|
|
31
|
+
# @param model [String] The model to use (e.g., "claude-sonnet-5")
|
|
32
|
+
# @param messages [Array<Hash>] The conversation messages
|
|
33
|
+
# @param system [String, nil] System prompt (top-level parameter)
|
|
34
|
+
# @param max_tokens [Integer] Maximum tokens in the response (required by the API)
|
|
35
|
+
# @param temperature [Float, nil] Sampling temperature (omitted when nil)
|
|
36
|
+
# @param output_config [Hash, nil] Output configuration, e.g.
|
|
37
|
+
# { format: { type: "json_schema", schema: {...} } } for structured outputs
|
|
38
|
+
# @return [Hash] The API response
|
|
39
|
+
def messages(model:, messages:, system: nil, max_tokens: 1000, temperature: nil, output_config: nil)
|
|
40
|
+
body = {
|
|
41
|
+
model: model,
|
|
42
|
+
max_tokens: max_tokens,
|
|
43
|
+
messages: messages
|
|
44
|
+
}
|
|
45
|
+
body[:system] = system if system
|
|
46
|
+
body[:temperature] = temperature unless temperature.nil?
|
|
47
|
+
body[:output_config] = output_config if output_config
|
|
48
|
+
|
|
49
|
+
post("/messages", body)
|
|
50
|
+
rescue APIError => e
|
|
51
|
+
raise unless body.key?(:temperature) && e.status_code == 400 && e.message.match?(/temperature/i)
|
|
52
|
+
|
|
53
|
+
warn "Warning: model does not support the temperature parameter; retrying without it"
|
|
54
|
+
post("/messages", body.reject { |key, _| key == :temperature })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def request_headers
|
|
60
|
+
{
|
|
61
|
+
"x-api-key" => @access_token,
|
|
62
|
+
"anthropic-version" => ANTHROPIC_VERSION
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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#
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
65
102
|
nil
|
|
66
103
|
end
|
|
67
104
|
|
|
68
|
-
# Generates text embeddings using
|
|
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:
|
|
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
|
-
|
|
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
|
data/lib/ruby-spacy/version.rb
CHANGED
data/lib/ruby-spacy.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ruby-spacy/version"
|
|
4
|
+
require_relative "ruby-spacy/llm_client_base"
|
|
4
5
|
require_relative "ruby-spacy/openai_client"
|
|
6
|
+
require_relative "ruby-spacy/anthropic_client"
|
|
5
7
|
require_relative "ruby-spacy/openai_helper"
|
|
8
|
+
require_relative "ruby-spacy/anthropic_helper"
|
|
6
9
|
require "numpy"
|
|
7
10
|
require "pycall"
|
|
8
11
|
require "timeout"
|
|
@@ -310,7 +313,8 @@ module Spacy
|
|
|
310
313
|
# @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
|
|
311
314
|
# @param max_completion_tokens [Integer] Maximum tokens in the response
|
|
312
315
|
# @param max_tokens [Integer] Alias for max_completion_tokens (deprecated, for backward compatibility)
|
|
313
|
-
# @param temperature [Float] Sampling temperature (
|
|
316
|
+
# @param temperature [Float, nil] Sampling temperature (omitted from requests
|
|
317
|
+
# when nil; models that reject it are retried without it)
|
|
314
318
|
# @param model [String] The model to use (default: gpt-5-mini)
|
|
315
319
|
# @param messages [Array<Hash>] Conversation history (for recursive tool calls). Note: this array is modified in place when tool calls occur.
|
|
316
320
|
# @param prompt [String, nil] System prompt for the query
|
|
@@ -318,8 +322,8 @@ module Spacy
|
|
|
318
322
|
def openai_query(access_token: nil,
|
|
319
323
|
max_completion_tokens: nil,
|
|
320
324
|
max_tokens: nil,
|
|
321
|
-
temperature:
|
|
322
|
-
model:
|
|
325
|
+
temperature: nil,
|
|
326
|
+
model: OpenAIClient::DEFAULT_MODEL,
|
|
323
327
|
messages: [],
|
|
324
328
|
prompt: nil,
|
|
325
329
|
response_format: nil,
|
|
@@ -420,7 +424,7 @@ module Spacy
|
|
|
420
424
|
message["content"]
|
|
421
425
|
end
|
|
422
426
|
rescue OpenAIClient::APIError => e
|
|
423
|
-
|
|
427
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
424
428
|
nil
|
|
425
429
|
end
|
|
426
430
|
|
|
@@ -429,10 +433,11 @@ module Spacy
|
|
|
429
433
|
# @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
|
|
430
434
|
# @param max_completion_tokens [Integer] Maximum tokens in the response
|
|
431
435
|
# @param max_tokens [Integer] Alias for max_completion_tokens (deprecated, for backward compatibility)
|
|
432
|
-
# @param temperature [Float] Sampling temperature (
|
|
436
|
+
# @param temperature [Float, nil] Sampling temperature (omitted from requests
|
|
437
|
+
# when nil; models that reject it are retried without it)
|
|
433
438
|
# @param model [String] The model to use (default: gpt-5-mini)
|
|
434
439
|
# @return [String, nil] The completed text
|
|
435
|
-
def openai_completion(access_token: nil, max_completion_tokens: nil, max_tokens: nil, temperature:
|
|
440
|
+
def openai_completion(access_token: nil, max_completion_tokens: nil, max_tokens: nil, temperature: nil, model: OpenAIClient::DEFAULT_MODEL)
|
|
436
441
|
# Support both max_completion_tokens and max_tokens for backward compatibility
|
|
437
442
|
max_completion_tokens ||= max_tokens || 1000
|
|
438
443
|
|
|
@@ -450,7 +455,7 @@ module Spacy
|
|
|
450
455
|
)
|
|
451
456
|
response.dig("choices", 0, "message", "content")
|
|
452
457
|
rescue OpenAIClient::APIError => e
|
|
453
|
-
|
|
458
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
454
459
|
nil
|
|
455
460
|
end
|
|
456
461
|
|
|
@@ -460,12 +465,12 @@ module Spacy
|
|
|
460
465
|
# @param model [String] The embeddings model (default: text-embedding-3-small)
|
|
461
466
|
# @param dimensions [Integer, nil] The number of dimensions for the output embeddings (nil uses model default)
|
|
462
467
|
# @return [Array<Float>, nil] The embedding vector
|
|
463
|
-
def openai_embeddings(access_token: nil, model:
|
|
468
|
+
def openai_embeddings(access_token: nil, model: OpenAIClient::DEFAULT_EMBEDDINGS_MODEL, dimensions: nil)
|
|
464
469
|
client = openai_client(access_token)
|
|
465
470
|
response = client.embeddings(model: model, input: @text, dimensions: dimensions)
|
|
466
471
|
response.dig("data", 0, "embedding")
|
|
467
472
|
rescue OpenAIClient::APIError => e
|
|
468
|
-
|
|
473
|
+
warn "Error: OpenAI API call failed - #{e.message}"
|
|
469
474
|
nil
|
|
470
475
|
end
|
|
471
476
|
|
|
@@ -618,14 +623,56 @@ module Spacy
|
|
|
618
623
|
end
|
|
619
624
|
end
|
|
620
625
|
|
|
621
|
-
# Yields
|
|
626
|
+
# Yields a provider-specific LLM helper for making API calls within a block.
|
|
622
627
|
# The helper is configured once and reused for all calls within the block,
|
|
623
628
|
# making it efficient for batch processing with {#pipe}.
|
|
624
629
|
#
|
|
630
|
+
# Providers:
|
|
631
|
+
# - +:openai+ — OpenAI API (or any OpenAI-compatible endpoint via +base_url:+).
|
|
632
|
+
# Yields an {OpenAIHelper}.
|
|
633
|
+
# - +:anthropic+ — Anthropic (Claude) Messages API. Yields an {AnthropicHelper}.
|
|
634
|
+
# - +:ollama+ — shortcut for a local Ollama server
|
|
635
|
+
# (+base_url: "http://localhost:11434/v1"+, no API key needed).
|
|
636
|
+
# Yields an {OpenAIHelper}.
|
|
637
|
+
#
|
|
638
|
+
# @param provider [Symbol] :openai (default), :anthropic, or :ollama
|
|
639
|
+
# @param opts [Hash] helper options (access_token:, model:, max_tokens:,
|
|
640
|
+
# temperature:, base_url:, ...) — see {OpenAIHelper#initialize} and
|
|
641
|
+
# {AnthropicHelper#initialize}
|
|
642
|
+
# @yield [OpenAIHelper, AnthropicHelper] the helper instance for making API calls
|
|
643
|
+
# @return [Object] the block's return value
|
|
644
|
+
# @example Claude
|
|
645
|
+
# nlp.with_llm(provider: :anthropic) do |ai|
|
|
646
|
+
# ai.chat(system: "Analyze.", user: doc.linguistic_summary)
|
|
647
|
+
# end
|
|
648
|
+
# @example Local model via Ollama
|
|
649
|
+
# nlp.with_llm(provider: :ollama, model: "llama3.2") do |ai|
|
|
650
|
+
# ai.chat(user: "Say hello.")
|
|
651
|
+
# end
|
|
652
|
+
def with_llm(provider: :openai, **opts)
|
|
653
|
+
helper = case provider.to_sym
|
|
654
|
+
when :openai
|
|
655
|
+
OpenAIHelper.new(**opts)
|
|
656
|
+
when :anthropic
|
|
657
|
+
AnthropicHelper.new(**opts)
|
|
658
|
+
when :ollama
|
|
659
|
+
OpenAIHelper.new(**{ base_url: "http://localhost:11434/v1",
|
|
660
|
+
access_token: "ollama" }.merge(opts))
|
|
661
|
+
else
|
|
662
|
+
raise ArgumentError, "Unknown LLM provider: #{provider} (use :openai, :anthropic, or :ollama)"
|
|
663
|
+
end
|
|
664
|
+
yield helper
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Yields an {OpenAIHelper} instance for making OpenAI API calls within a block.
|
|
668
|
+
# Equivalent to {#with_llm} with +provider: :openai+.
|
|
669
|
+
#
|
|
625
670
|
# @param access_token [String, nil] OpenAI API key (defaults to OPENAI_API_KEY env var)
|
|
626
671
|
# @param model [String] the default model for chat requests
|
|
627
672
|
# @param max_completion_tokens [Integer] default maximum tokens in responses
|
|
628
|
-
# @param temperature [Float] default sampling temperature
|
|
673
|
+
# @param temperature [Float, nil] default sampling temperature (omitted from
|
|
674
|
+
# requests when nil)
|
|
675
|
+
# @param base_url [String, nil] OpenAI-compatible API endpoint override
|
|
629
676
|
# @yield [OpenAIHelper] the helper instance for making API calls
|
|
630
677
|
# @return [Object] the block's return value
|
|
631
678
|
# @example Batch processing with pipe
|
|
@@ -634,13 +681,14 @@ module Spacy
|
|
|
634
681
|
# ai.chat(system: "Analyze.", user: doc.linguistic_summary)
|
|
635
682
|
# end
|
|
636
683
|
# end
|
|
637
|
-
def with_openai(access_token: nil, model:
|
|
638
|
-
max_completion_tokens: 1000, temperature:
|
|
684
|
+
def with_openai(access_token: nil, model: OpenAIClient::DEFAULT_MODEL,
|
|
685
|
+
max_completion_tokens: 1000, temperature: nil, base_url: nil)
|
|
639
686
|
helper = OpenAIHelper.new(
|
|
640
687
|
access_token: access_token,
|
|
641
688
|
model: model,
|
|
642
689
|
max_completion_tokens: max_completion_tokens,
|
|
643
|
-
temperature: temperature
|
|
690
|
+
temperature: temperature,
|
|
691
|
+
base_url: base_url
|
|
644
692
|
)
|
|
645
693
|
yield helper
|
|
646
694
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-spacy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yoichiro Hasebe
|
|
@@ -208,6 +208,9 @@ files:
|
|
|
208
208
|
- examples/linguistic_features/similarity_between_lexemes.rb
|
|
209
209
|
- examples/linguistic_features/similarity_between_spans.rb
|
|
210
210
|
- examples/linguistic_features/tokenization.rb
|
|
211
|
+
- examples/llm/anthropic_chat.rb
|
|
212
|
+
- examples/llm/local_llm_ollama.rb
|
|
213
|
+
- examples/llm/structured_ner_comparison.rb
|
|
211
214
|
- examples/openai_integration/openai_completion.rb
|
|
212
215
|
- examples/openai_integration/openai_embeddings.rb
|
|
213
216
|
- examples/openai_integration/openai_query_1.rb
|
|
@@ -217,6 +220,9 @@ files:
|
|
|
217
220
|
- examples/rule_based_matching/creating_spans_from_matches.rb
|
|
218
221
|
- examples/rule_based_matching/matcher.rb
|
|
219
222
|
- lib/ruby-spacy.rb
|
|
223
|
+
- lib/ruby-spacy/anthropic_client.rb
|
|
224
|
+
- lib/ruby-spacy/anthropic_helper.rb
|
|
225
|
+
- lib/ruby-spacy/llm_client_base.rb
|
|
220
226
|
- lib/ruby-spacy/openai_client.rb
|
|
221
227
|
- lib/ruby-spacy/openai_helper.rb
|
|
222
228
|
- lib/ruby-spacy/version.rb
|