inquirex-llm 0.10.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa6e28413bea905a4f70e49fa7a93465bbf0444dbc8594689a7c1adf92cfadd5
4
- data.tar.gz: 86a367575aacb7639364196d17189073b0aa621b666b28c90aeec08e5ee6ae33
3
+ metadata.gz: ac542fe51e67d8477ceb8b7b45facb1af3e79b4794cd69d9e62a158d40931df3
4
+ data.tar.gz: 0a995561405b2cf3838c72a696ba2c20e55d9fb185cbd44bbf8f0a9dd02fffe2
5
5
  SHA512:
6
- metadata.gz: f9eb6d18836ee810231421e172b4a36c7b4cae7a4f01681dc7c80577a031b50cf75a7ae8c96deeaea00c2aa08223534222654bd774dcfdd6affc3fa73972ce28
7
- data.tar.gz: 57440f09e0446239c3f5f0b1fbb4535127b5c7d5e09c707dd1824d82101a9fd244e4d3447e933cf960cf852f2fbccd80c5a3459dc6faf0df84cbcecc422df209
6
+ metadata.gz: 3aa789c543358cd2a3cf12423e252fa07673fcedf6cdd950db4a205d3cea62eaebf2f493207989c3aaf4b89c8a89cc714b5d54fb5a9bfa50fc24e7737f5cded4
7
+ data.tar.gz: cde3f862aa1fe7f3bac1988290c93595e997df98e3fc22798e8ff146b757f3552f39d7f24129b95a19fa15ce71236f21332d290cfdbe98e8633d203aeff480f7
data/CHANGELOG.md CHANGED
@@ -1,12 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 (unreleased)
4
+
5
+ Hand-written until `github_changelog_generator` regenerates this file at release. The family-wide notes are in inquirex's `INQUIREX_VERSION_LOG.md`.
6
+
7
+ - **Current Claude model ids.** `AnthropicAdapter::MODELS` lists `claude-fable-5-1`, `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`; the default (`claude-sonnet-5-5`) and the `:claude_sonnet`, `:claude_haiku`, `:claude_opus` and new `:claude_fable` aliases are looked up in it. 0.10.0 defaulted to `claude-sonnet-4-20250514`.
8
+ - **`Inquirex::LLM.configuration`.** Provider, API keys, default models, token limits, temperatures and debug are set in one place, from ENV first (`INQUIREX_LLM_ADAPTER`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, new `INQUIREX_LLM_ANTHROPIC_MODEL`, `INQUIREX_LLM_OPENAI_MODEL`, `INQUIREX_LLM_MAX_TOKENS`, `INQUIREX_LLM_TEMPERATURE`, `INQUIREX_LLM_SUMMARY_MAX_TOKENS`, `INQUIREX_LLM_SUMMARY_TEMPERATURE`, `INQUIREX_DEBUG`), then today's defaults; `Inquirex::LLM.configure` overrides them and `reset_configuration!` re-reads ENV. Invalid values raise `Errors::ConfigurationError` naming the variable. Model ids moved to `Inquirex::LLM::Models`; the adapter constants still answer. `Inquirex::LLM.adapter` builds the adapter with the selection rules inquirex-tty used to carry.
9
+ - **`OpenRouterAdapter`.** Any OpenRouter model through the OpenAI adapter's prompts and parsing, with an optional reasoning `effort:`. New `OPENROUTER_API_KEY` and `INQUIREX_LLM_OPENROUTER_MODEL` (default `anthropic/claude-sonnet-5.5`).
10
+ - **Extraction result object.** An `extract` call returns an `Inquirex::LLM::Extraction`: the schema-shaped Hash it always returned (it is a `Hash`, so `prefill!`, `==` and JSON are unchanged) with `raw`, the JSON exactly as the model sent it before enum values were canonicalized, and `usage`, the tokens and cost the provider reported. One LLM call per extraction, as before.
11
+ - **`JevAdapter`.** TypeSafe's Jev through its native typed-question API: `enum`, `boolean`, `multi_enum` and `integer` fields only, one request per step, `min_confidence:` to leave unsure fields unset, and per-field confidence in the `Extraction#raw`. Reads `TYPESAFE_API_KEY` and `INQUIREX_LLM_JEV_MODEL`, whose default is now `jev-latest` (was the unverified `jev-1.13.0`). Neither new adapter is auto-detected; `INQUIREX_LLM_ADAPTER` accepts `openrouter` and `jev`.
12
+ - **The extraction eval suite now runs on agent-evals.** `evals/` holds `.agent-eval.config.yml` and `datasets/<NN-slug>/` (dsl.rb, schema.rb, `cases/{complete,vague}/`); the adapter and the five-outcome scorer are `Inquirex::LLM::Evals`, in the gem but loaded only by `require "inquirex/llm/evals"`. The standalone `extract-eval` runner is gone; its results are in `docs/evals-history.md`.
13
+ - **Fix:** an `OpenAIAdapter` subclass's `MODEL_MAP` was ignored; model symbols always resolved through OpenAI's map.
14
+ - **No `temperature` for current models.** They answer any sampling parameter with HTTP 400, so the adapter omits it for ids in `MODELS` and still sends it to a model named by an older id.
15
+ - **Requires `inquirex ~> 0.12`**, which declares `prism` as a runtime dependency and so loads on Ruby 4.1.
16
+ - `spec/inquirex/llm/gemspec_spec.rb` fails when `lib/` requires a library that neither ships with the running Ruby nor is a declared runtime dependency.
17
+ - CircleCI config copied from inquirex core; `.ruby-version` pins 4.1.0-preview1.
18
+
19
+ ## [v0.10.0](https://github.com/inquirex/inquirex-llm/tree/v0.10.0) (2026-08-17)
20
+
21
+ [Full Changelog](https://github.com/inquirex/inquirex-llm/compare/v0.8.0...v0.10.0)
22
+
23
+ **Merged pull requests:**
24
+
25
+ - Set the lockstep version to 0.10.0 [#7](https://github.com/inquirex/inquirex-llm/pull/7) ([kigster](https://github.com/kigster))
26
+ - Bump to 0.9.6 [#6](https://github.com/inquirex/inquirex-llm/pull/6) ([kigster](https://github.com/kigster))
27
+ - Add the summarize verb [#5](https://github.com/inquirex/inquirex-llm/pull/5) ([kigster](https://github.com/kigster))
28
+ - Canonicalize LLM output against schema values [#3](https://github.com/inquirex/inquirex-llm/pull/3) ([kigster](https://github.com/kigster))
29
+
30
+ ## [v0.8.0](https://github.com/inquirex/inquirex-llm/tree/v0.8.0) (2026-08-01)
31
+
32
+ [Full Changelog](https://github.com/inquirex/inquirex-llm/compare/v0.6.0...v0.8.0)
33
+
3
34
  ## [v0.6.0](https://github.com/inquirex/inquirex-llm/tree/v0.6.0) (2026-07-20)
4
35
 
5
36
  [Full Changelog](https://github.com/inquirex/inquirex-llm/compare/v0.5.0...v0.6.0)
6
37
 
7
38
  **Merged pull requests:**
8
39
 
9
- - Resolve extract schemas from question references [\#2](https://github.com/inquirex/inquirex-llm/pull/2) ([kigster](https://github.com/kigster))
40
+ - Resolve extract schemas from question references [#2](https://github.com/inquirex/inquirex-llm/pull/2) ([kigster](https://github.com/kigster))
10
41
 
11
42
  ## [v0.5.0](https://github.com/inquirex/inquirex-llm/tree/v0.5.0) (2026-07-16)
12
43
 
@@ -14,7 +45,7 @@
14
45
 
15
46
  **Merged pull requests:**
16
47
 
17
- - Reduce LLM verbs to `extract` and release v0.5.0 [\#1](https://github.com/inquirex/inquirex-llm/pull/1) ([kigster](https://github.com/kigster))
48
+ - Reduce LLM verbs to `extract` and release v0.5.0 [#1](https://github.com/inquirex/inquirex-llm/pull/1) ([kigster](https://github.com/kigster))
18
49
 
19
50
  ## [v0.4.1](https://github.com/inquirex/inquirex-llm/tree/v0.4.1) (2026-07-13)
20
51
 
@@ -28,6 +59,4 @@
28
59
 
29
60
  [Full Changelog](https://github.com/inquirex/inquirex-llm/compare/809890f89aa8230b34674820debf7a68ced283e5...v0.1.0)
30
61
 
31
-
32
-
33
62
  \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
data/README.md CHANGED
@@ -228,6 +228,8 @@ engine.answer(result)
228
228
  # engine.current_step_id => :summary
229
229
  ```
230
230
 
231
+ The built-in adapters return an `Inquirex::LLM::Extraction` for an `extract` step. It is the Hash above (a `Hash` subclass, so `prefill!`, `==` and JSON serialization see only the values) carrying two more things from the one LLM call that produced it: `raw`, the JSON exactly as the model sent it before enum values were canonicalized (`raw[:entity_type] # => "LLC"` where `result[:entity_type] # => "llc"`), and `usage`, the tokens and, when the provider reports it, the cost.
232
+
231
233
  For testing, use `NullAdapter` which returns schema-conformant placeholder values without any API calls:
232
234
 
233
235
  ```ruby
@@ -236,39 +238,116 @@ result = adapter.call(engine.current_step)
236
238
  # => { industry: "", employee_count: 0, revenue: 0.0 }
237
239
  ```
238
240
 
241
+ ## Configuration
242
+
243
+ Model ids, the provider and its credentials are set in one place, `Inquirex::LLM.configuration`. Each setting is read from its environment variable when that variable is set and not blank, and otherwise takes the default below. The adapters fall back on it whenever no `api_key:` or `model:` is passed and a node sets no `max_tokens` or `temperature` of its own.
244
+
245
+ | Variable | Setting | Default |
246
+ | ---------------------------------- | --------------------- | ---------------------------------------------------------------------- |
247
+ | `INQUIREX_LLM_ADAPTER` | `provider` | auto-detect; else `anthropic`, `openai`, `openrouter`, `jev` or `null` |
248
+ | `ANTHROPIC_API_KEY` | `anthropic_api_key` | none |
249
+ | `OPENAI_API_KEY` | `openai_api_key` | none |
250
+ | `OPENROUTER_API_KEY` | `openrouter_api_key` | none |
251
+ | `TYPESAFE_API_KEY` | `typesafe_api_key` | none |
252
+ | `INQUIREX_LLM_ANTHROPIC_MODEL` | `anthropic_model` | `Models::ANTHROPIC_DEFAULT` (`claude-sonnet-5-5`) |
253
+ | `INQUIREX_LLM_OPENAI_MODEL` | `openai_model` | `Models::OPENAI_DEFAULT` (`gpt-4o-mini`) |
254
+ | `INQUIREX_LLM_OPENROUTER_MODEL` | `openrouter_model` | `Models::OPENROUTER_DEFAULT` (`anthropic/claude-sonnet-5.5`) |
255
+ | `INQUIREX_LLM_JEV_MODEL` | `jev_model` | `Models::JEV_DEFAULT` (`jev-latest`) |
256
+ | `INQUIREX_LLM_MAX_TOKENS` | `max_tokens` | `2048` |
257
+ | `INQUIREX_LLM_TEMPERATURE` | `temperature` | `0.2` |
258
+ | `INQUIREX_LLM_SUMMARY_MAX_TOKENS` | `summary_max_tokens` | `4096` |
259
+ | `INQUIREX_LLM_SUMMARY_TEMPERATURE` | `summary_temperature` | `0.4` |
260
+ | `INQUIREX_DEBUG` | `debug` | off; any non-blank value turns it on |
261
+
262
+ An invalid value (an unknown provider, a token count that is not a positive integer, a temperature outside 0 to 2) raises `Inquirex::LLM::Errors::ConfigurationError` naming the variable. `to_h` and `inspect` print every `*_api_key` as `[REDACTED]`.
263
+
264
+ ENV is read once, on first use. Code can override any setting afterwards; `nil` restores the default:
265
+
266
+ ```ruby
267
+ Inquirex::LLM.configure do |c|
268
+ c.provider = :anthropic
269
+ c.anthropic_model = "claude-opus-5-5"
270
+ c.max_tokens = 4096
271
+ end
272
+
273
+ adapter = Inquirex::LLM.adapter # built from the configuration
274
+ Inquirex::LLM.reset_configuration! # discard overrides and read ENV again
275
+ ```
276
+
277
+ `Inquirex::LLM.adapter` picks the adapter, first match wins:
278
+
279
+ | Condition | Adapter |
280
+ | --------------------------- | ---------------------------------------- |
281
+ | `provider` is `:null` | `NullAdapter` |
282
+ | `provider` is `:anthropic` | `AnthropicAdapter` (needs key) |
283
+ | `provider` is `:openai` | `OpenAIAdapter` (needs key) |
284
+ | `provider` is `:openrouter` | `OpenRouterAdapter` (needs key) |
285
+ | `provider` is `:jev` | `JevAdapter` (needs key; `extract` only) |
286
+ | `anthropic_api_key` is set | `AnthropicAdapter` |
287
+ | `openai_api_key` is set | `OpenAIAdapter` |
288
+ | otherwise | `NullAdapter` |
289
+
290
+ `OPENROUTER_API_KEY` and `TYPESAFE_API_KEY` never select an adapter on their own: a host that has one of those keys for something else keeps the adapter it had, and Jev cannot summarize, so both must be asked for by name.
291
+
239
292
  ## Built-in Adapters
240
293
 
241
- | Class | Provider | API | Auth | Key env var |
242
- | --------------------------------------- | --------- | ---------------------------------- | ------------------------- | ------------------- |
243
- | `Inquirex::LLM::NullAdapter` | — | none (placeholders) | none | — |
244
- | `Inquirex::LLM::AnthropicAdapter` | Anthropic | `/v1/messages` | `x-api-key` header | `ANTHROPIC_API_KEY` |
245
- | `Inquirex::LLM::OpenAIAdapter` | OpenAI | `/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENAI_API_KEY` |
246
- | `Inquirex::LLM::LittleLLMAdapter` (TBD) | Any | OpenAI Compatible API | OpenAI Compatible Auth | Provider Specific |
294
+ | Class | Provider | API | Auth | Key env var |
295
+ | ---------------------------------- | ---------- | -------------------------------------- | ------------------------- | -------------------- |
296
+ | `Inquirex::LLM::NullAdapter` | — | none (placeholders) | none | — |
297
+ | `Inquirex::LLM::AnthropicAdapter` | Anthropic | `/v1/messages` | `x-api-key` header | `ANTHROPIC_API_KEY` |
298
+ | `Inquirex::LLM::OpenAIAdapter` | OpenAI | `/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENAI_API_KEY` |
299
+ | `Inquirex::LLM::OpenRouterAdapter` | OpenRouter | `/api/v1/chat/completions` (JSON mode) | `Authorization: Bearer …` | `OPENROUTER_API_KEY` |
300
+ | `Inquirex::LLM::JevAdapter` | TypeSafe | `/v1/systemone` (typed questions) | `Authorization: Bearer …` | `TYPESAFE_API_KEY` |
301
+
302
+ OpenRouter and Jev are never auto-detected; select them with `INQUIREX_LLM_ADAPTER` or build them directly.
247
303
 
248
- The Anthropic and OpenAI adapters use `net/http` (stdlib, no extra dependency), inject the declared `schema` into the system prompt as a strict JSON contract, and raise `Inquirex::LLM::Errors::AdapterError` on HTTP / parse failures and `SchemaViolationError` when the model's output is missing declared fields.
304
+ All adapters use `net/http` (stdlib, no extra dependency), inject the declared `schema` into the system prompt as a strict JSON contract, and raise `Inquirex::LLM::Errors::AdapterError` on HTTP / parse failures and `SchemaViolationError` when the model's output is missing declared fields.
249
305
 
250
306
  ### AnthropicAdapter
251
307
 
252
308
  ```ruby
253
- adapter = Inquirex::LLM::AnthropicAdapter.new(
254
- api_key: ENV["ANTHROPIC_API_KEY"],
255
- model: "claude-sonnet-4-20250514" # or pass the short symbol in the DSL
309
+ adapter = Inquirex::LLM::AnthropicAdapter.new # key and model from the configuration
310
+ adapter = Inquirex::LLM::AnthropicAdapter.new( # or name them; arguments win
311
+ api_key: "sk-ant-...",
312
+ model: "claude-opus-5-5" # or pass the short symbol in the DSL
256
313
  )
257
314
  ```
258
315
 
259
- Recognized `model :symbol` values in the DSL: `:claude_sonnet`, `:claude_haiku`, `:claude_opus` (mapped to the current concrete model ids).
316
+ The current model ids live in one constant, `Inquirex::LLM::Models::ANTHROPIC` (also reachable as `AnthropicAdapter::MODELS`): `claude-fable-5-1`, `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5`. The default is `Models::ANTHROPIC_DEFAULT`, `claude-sonnet-5-5`, unless `INQUIREX_LLM_ANTHROPIC_MODEL` names another. Recognized `model :symbol` values in the DSL: `:claude_sonnet`, `:claude_haiku`, `:claude_opus` and `:claude_fable`, each mapped to its family's id in `MODELS`. `:claude_fable` is Anthropic-only; the OpenAI adapter does not map it.
317
+
318
+ These models reject sampling parameters, so the adapter does not send `temperature` to them. A `temperature` in the DSL still reaches a model you name by an older id, such as `model: "claude-sonnet-4-6"`.
260
319
 
261
320
  ### OpenAIAdapter
262
321
 
263
322
  ```ruby
264
- adapter = Inquirex::LLM::OpenAIAdapter.new(
265
- api_key: ENV["OPENAI_API_KEY"],
266
- model: "gpt-4o-mini"
267
- )
323
+ adapter = Inquirex::LLM::OpenAIAdapter.new # key and model from the configuration
324
+ adapter = Inquirex::LLM::OpenAIAdapter.new(api_key: "sk-...", model: "gpt-4.1")
268
325
  ```
269
326
 
270
327
  Uses Chat Completions with `response_format: { type: "json_object" }` so the model is constrained to return valid JSON. Recognized DSL symbols: `:gpt_4o`, `:gpt_4o_mini`, `:gpt_4_1`, `:gpt_4_1_mini`. For cross-provider portability, the adapter also accepts the Claude symbols (`:claude_sonnet` → `gpt-4o` etc.) so a flow file that says `model :claude_sonnet` runs unchanged against either provider.
271
328
 
329
+ ### OpenRouterAdapter
330
+
331
+ ```ruby
332
+ adapter = Inquirex::LLM::OpenRouterAdapter.new # key and model from the configuration
333
+ adapter = Inquirex::LLM::OpenRouterAdapter.new(model: "google/gemini-2.5-flash", effort: "low")
334
+ ```
335
+
336
+ Any model OpenRouter hosts, with the OpenAI adapter's prompts and parsing. The Claude and GPT symbols map to OpenRouter ids (`:claude_haiku` → `anthropic/claude-haiku-5.5`); any other model is sent as written. `effort:` (`none` … `max`) becomes OpenRouter's `reasoning.effort`; without it the model decides.
337
+
338
+ ### JevAdapter
339
+
340
+ ```ruby
341
+ adapter = Inquirex::LLM::JevAdapter.new # key and model from the configuration
342
+ adapter = Inquirex::LLM::JevAdapter.new(min_confidence: 0.6) # less sure than 0.6 → nil
343
+ extraction = adapter.call(node, answers) # the values, as every adapter returns them
344
+ extraction.raw.dig("filing_status", "confidence") # how sure Jev was, from TypeSafe's answer
345
+ ```
346
+
347
+ Jev, TypeSafe's System One model, answers typed questions with calibrated probabilities instead of writing JSON. The adapter turns each schema field into questions and sends them all in one request: a Choice per `enum` (plus "not stated"), a yes / no / not-stated Choice per `boolean`, a presence Choice plus a Noul per value for `multi_enum`, and for `integer` a Choice among the numbers the text states (digits or words, `Inquirex::LLM::Numbers`) and the counts 0 to 12. It retries 429 and 529 twice with backoff.
348
+
349
+ Jev chooses; it cannot write. A schema with a `string`, `text`, `date`, `email`, `phone` or other free-form field raises `AdapterError` before anything is sent (`JevAdapter.unsupported_fields(schema)` checks a flow up front), and `summarize` always raises. Route those to a text model.
350
+
272
351
  ## LLM-assisted Pre-fill Pattern
273
352
 
274
353
  A common use case: ask *one* open-ended question, let the LLM extract answers for *many* downstream questions, and only prompt the user for what the LLM couldn't determine. This is what the core engine's `Engine#prefill!` is for:
@@ -406,6 +485,30 @@ detour :followup do
406
485
  end
407
486
  ```
408
487
 
488
+ ## Evals
489
+
490
+ Which model extracts best, measured with this gem's own prompts: ten flows, a complete and a vague text each, run on [agent-evals](https://github.com/kigster/agent-evals). The lineup and the data live in [`evals/`](evals/README.md); the adapter and the scorer are `Inquirex::LLM::Evals`, loaded only by `require "inquirex/llm/evals"`.
491
+
492
+ How a run works:
493
+
494
+ 1. **Verify** (offline): each dataset's `schema.rb` must match the `extract` step in its `dsl.rb`, and every `expected.json` must fit that schema.
495
+ 1. **Expand the lineup**: every model under `vendors:` in `evals/.agent-eval.config.yml`, times each `effort`/`temperature` it lists, becomes one contender. Each contender runs every case.
496
+ 1. **Call the model**: one OpenRouter chat completion per case, built from this gem's production prompt and the step's schema.
497
+ 1. **Score the raw reply** field by field: `correct` 1.0, `missed` 0.2, `hallucinated` 0.15, `incorrect` 0.1, `invalid` 0. A case's score is the mean over its fields. The rules are in [`evals/README.md`](evals/README.md#scoring).
498
+ 1. **Report**: a leaderboard on screen and a JSON log under `log/agent-evals/`. If `BRAINTRUST_API_KEY` or `LOGFIRE_TOKEN` is set, each case is also sent to that one tracker; `--local` turns that off.
499
+
500
+ ```bash
501
+ just evals -n # the whole matrix, calling nothing
502
+ just evals # every configured model on every dataset
503
+ just evals --local -d 05-managed-it-services # one dataset, no remote tracker
504
+ just evals-verify # step 1 only
505
+
506
+ bundle exec agent-evals cases # the 20 cases
507
+ bundle exec agent-evals run -m openai/gpt-4o-mini # one model, per-case breakdown
508
+ ```
509
+
510
+ `just evals` needs `OPENROUTER_API_KEY` and the `:evals` bundle group. It is a paid run: 14 contenders × 20 cases = 280 calls.
511
+
409
512
  ## Development
410
513
 
411
514
  ```bash
@@ -128,6 +128,22 @@ module Inquirex
128
128
 
129
129
  protected
130
130
 
131
+ # The tokens a response reports, in one shape whatever the provider's:
132
+ # Anthropic counts input_tokens/output_tokens, OpenAI and OpenRouter
133
+ # prompt_tokens/completion_tokens, and OpenRouter adds the cost it
134
+ # billed. Empty when the response carries no usage at all.
135
+ #
136
+ # @param usage [Hash, nil] the response's "usage" object
137
+ # @return [Hash{Symbol => Numeric}] input_tokens, output_tokens, and cost_usd when reported
138
+ def usage_from(usage)
139
+ return {} unless usage.is_a?(Hash)
140
+
141
+ counts = usage.transform_keys(&:to_s)
142
+ { input_tokens: (counts["input_tokens"] || counts["prompt_tokens"]).to_i,
143
+ output_tokens: (counts["output_tokens"] || counts["completion_tokens"]).to_i,
144
+ cost_usd: counts["cost"]&.to_f }.compact
145
+ end
146
+
131
147
  # The canonical form value for a raw LLM answer, or nil when the answer
132
148
  # is outside the allowed list (after trimming and case-folding).
133
149
  #
@@ -166,6 +182,18 @@ module Inquirex
166
182
  "\"multi_enum\" fields. Never invent a value outside the list."
167
183
  end
168
184
 
185
+ # Prompt instruction for fields the source text does not answer. They
186
+ # must come back null: 0, "", false and [] are real answers (no
187
+ # dependents, not self-employed), and the engine prefills them and
188
+ # skips the question as though the user had said so.
189
+ #
190
+ # @return [String]
191
+ def missing_values_instruction
192
+ "Every key in the schema must be present in your output. When the source text does not " \
193
+ "provide a value, use null. Never use 0, \"\", false, or [] for a value that is missing: " \
194
+ "those are answers, and they would be recorded as what the user said."
195
+ end
196
+
169
197
  # One human-readable line per schema field, used in user prompts:
170
198
  # income_types (multi_enum: W2 | business | crypto)
171
199
  # dependents (integer)
@@ -4,16 +4,16 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "inquirex/llm/adapter"
7
+ require "inquirex/llm/extraction"
8
+ require "inquirex/llm/configuration"
7
9
 
8
10
  module Inquirex
9
11
  module LLM
10
12
  # Real Anthropic Claude adapter for inquirex-llm.
11
13
  #
12
14
  # Usage:
13
- # adapter = Inquirex::LLM::AnthropicAdapter.new(
14
- # api_key: ENV["ANTHROPIC_API_KEY"],
15
- # model: "claude-sonnet-4-20250514"
16
- # )
15
+ # adapter = Inquirex::LLM::AnthropicAdapter.new # key and model from Inquirex::LLM.configuration
16
+ # adapter = Inquirex::LLM::AnthropicAdapter.new(api_key: "sk-...", model: "claude-opus-5-5")
17
17
  # result = adapter.call(engine.current_step, engine.answers)
18
18
  #
19
19
  # The adapter:
@@ -26,42 +26,38 @@ module Inquirex
26
26
  class AnthropicAdapter < Adapter
27
27
  API_URL = "https://api.anthropic.com/v1/messages"
28
28
  API_VERSION = "2023-06-01"
29
- DEFAULT_MODEL = "claude-sonnet-4-20250514"
30
- DEFAULT_MAX_TOKENS = 2048
31
-
32
- # Summaries want a little more room than an extraction (prose, not a
33
- # handful of fields) and a little more warmth than one (0.2 produces
34
- # summaries that read like a database dump).
35
- DEFAULT_SUMMARY_MAX_TOKENS = 4096
36
- DEFAULT_SUMMARY_TEMPERATURE = 0.4
37
-
38
- # Maps Inquirex short model symbols to concrete Anthropic model ids.
39
- MODEL_MAP = {
40
- claude_sonnet: "claude-sonnet-4-20250514",
41
- claude_haiku: "claude-haiku-4-5-20251001",
42
- claude_opus: "claude-opus-4-20250514"
43
- }.freeze
44
-
45
- # @param api_key [String, nil] defaults to ENV["ANTHROPIC_API_KEY"]
46
- # @param model [String, nil] default model id when a node does not specify one
29
+ # The current Claude model ids. Kept as aliases of {Models} so callers
30
+ # that read them here keep working; {Models} is where they are written.
31
+ MODELS = Models::ANTHROPIC
32
+ MODEL_MAP = Models::ANTHROPIC_ALIASES
33
+ DEFAULT_MODEL = Models::ANTHROPIC_DEFAULT
34
+
35
+ DEFAULT_MAX_TOKENS = Configuration::DEFAULT_MAX_TOKENS
36
+ DEFAULT_SUMMARY_MAX_TOKENS = Configuration::DEFAULT_SUMMARY_MAX_TOKENS
37
+ DEFAULT_SUMMARY_TEMPERATURE = Configuration::DEFAULT_SUMMARY_TEMPERATURE
38
+
39
+ # @param api_key [String, nil] defaults to the configuration's anthropic_api_key (ANTHROPIC_API_KEY)
40
+ # @param model [String, nil] default model id when a node does not specify one;
41
+ # defaults to the configuration's anthropic_model (INQUIREX_LLM_ANTHROPIC_MODEL)
42
+ # @raise [ArgumentError] when no key is passed or configured
47
43
  def initialize(api_key: nil, model: nil)
48
44
  super()
49
- @api_key = api_key || ENV.fetch("ANTHROPIC_API_KEY") {
50
- raise ArgumentError, "ANTHROPIC_API_KEY is required (pass api_key: or set the env var)"
51
- }
52
- @default_model = model || DEFAULT_MODEL
45
+ @api_key = api_key || LLM.configuration.anthropic_api_key
46
+ raise ArgumentError, "ANTHROPIC_API_KEY is required (pass api_key: or set the env var)" unless @api_key
47
+
48
+ @default_model = model || LLM.configuration.anthropic_model
53
49
  end
54
50
 
55
51
  # @param node [Inquirex::LLM::Node] the current LLM step
56
52
  # @param answers [Hash] all collected answers so far
57
- # @return [Hash] structured data matching the node's schema
53
+ # @return [Extraction] the schema-shaped values, with the model's raw JSON and the usage
58
54
  # @raise [Errors::AdapterError] on API / parse failures
59
55
  # @raise [Errors::SchemaViolationError] when the LLM output misses schema fields
60
56
  def call(node, answers = {})
61
57
  source = source_answers(node, answers)
62
58
  model = resolve_model(node)
63
- temperature = node.respond_to?(:temperature) ? (node.temperature || 0.2) : 0.2
64
- max_tokens = node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_MAX_TOKENS) : DEFAULT_MAX_TOKENS
59
+ temperature = node.respond_to?(:temperature) ? (node.temperature || config.temperature) : config.temperature
60
+ max_tokens = node.respond_to?(:max_tokens) ? (node.max_tokens || config.max_tokens) : config.max_tokens
65
61
 
66
62
  response = call_api(
67
63
  model: model,
@@ -71,7 +67,8 @@ module Inquirex
71
67
  max_tokens: max_tokens
72
68
  )
73
69
 
74
- result = normalize_output(node, parse_response(response))
70
+ raw = parse_response(response)
71
+ result = Extraction.new(output: normalize_output(node, raw), raw: raw, usage: usage_from(response["usage"]))
75
72
  validate_output!(node, result)
76
73
  result
77
74
  end
@@ -93,20 +90,38 @@ module Inquirex
93
90
  model: resolve_model(node),
94
91
  system: Prompts::SUMMARIZE,
95
92
  user: summary_input(transcript),
96
- temperature: node.respond_to?(:temperature) ? (node.temperature || DEFAULT_SUMMARY_TEMPERATURE) : DEFAULT_SUMMARY_TEMPERATURE,
97
- max_tokens: node.respond_to?(:max_tokens) ? (node.max_tokens || DEFAULT_SUMMARY_MAX_TOKENS) : DEFAULT_SUMMARY_MAX_TOKENS
93
+ temperature: node.respond_to?(:temperature) ? (node.temperature || config.summary_temperature) : config.summary_temperature,
94
+ max_tokens: node.respond_to?(:max_tokens) ? (node.max_tokens || config.summary_max_tokens) : config.summary_max_tokens
98
95
  )
99
96
  parse_text_response(response)
100
97
  end
101
98
 
102
99
  private
103
100
 
101
+ # Read on every call rather than kept from #initialize, so limits set
102
+ # through Inquirex::LLM.configure after the adapter was built still apply.
103
+ #
104
+ # @return [Configuration]
105
+ def config = LLM.configuration
106
+
104
107
  def resolve_model(node)
105
108
  return @default_model unless node.respond_to?(:model) && node.model
106
109
 
107
110
  MODEL_MAP[node.model.to_sym] || node.model.to_s
108
111
  end
109
112
 
113
+ # The models in {MODELS} reject sampling parameters: a request carrying
114
+ # `temperature` is answered with HTTP 400, whatever its value. The DSL's
115
+ # `temperature` is therefore sent only to an explicitly named older
116
+ # model that still accepts it.
117
+ #
118
+ # @param model [String] resolved model id
119
+ # @param temperature [Float]
120
+ # @return [Hash] `{}` or `{ temperature: }`
121
+ def sampling(model, temperature)
122
+ MODELS.include?(model) ? {} : { temperature: temperature }
123
+ end
124
+
110
125
  def build_system_prompt(node)
111
126
  "You are a data extraction assistant for a questionnaire engine. " \
112
127
  "Your job is to analyze user input and extract structured data." +
@@ -118,6 +133,7 @@ module Inquirex
118
133
  "\n\nYou MUST respond with ONLY a valid JSON object matching this schema:\n" \
119
134
  "#{schema_contract_json(node.schema)}\n" \
120
135
  "#{values_instruction(node.schema)}\n\n" \
136
+ "#{missing_values_instruction}\n" \
121
137
  "Do not include any text before or after the JSON. No markdown fences. Just the raw JSON object."
122
138
  else
123
139
  "\n\nRespond with a valid JSON object containing your analysis. " \
@@ -159,14 +175,14 @@ module Inquirex
159
175
  request["x-api-key"] = @api_key
160
176
  request["anthropic-version"] = API_VERSION
161
177
  request.body = JSON.generate(
162
- model: model,
163
- max_tokens: max_tokens,
164
- temperature: temperature,
165
- system: system,
166
- messages: [{ role: "user", content: user }]
178
+ model: model,
179
+ max_tokens: max_tokens,
180
+ **sampling(model, temperature),
181
+ system: system,
182
+ messages: [{ role: "user", content: user }]
167
183
  )
168
184
 
169
- warn "[inquirex-llm] Calling #{model}..." if ENV["INQUIREX_DEBUG"]
185
+ warn "[inquirex-llm] Calling #{model}..." if config.debug?
170
186
 
171
187
  response = http.request(request)
172
188
  unless response.is_a?(Net::HTTPSuccess)
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inquirex/llm/errors"
4
+ require "inquirex/llm/models"
5
+
6
+ module Inquirex
7
+ module LLM
8
+ # Gem-wide settings: which provider to use, its credentials, its default
9
+ # model, and the generation limits the adapters fall back on when a node
10
+ # does not set its own.
11
+ #
12
+ # Each setting is read from its environment variable when that variable
13
+ # is set and not blank, and otherwise takes the default below. ENV is read
14
+ # once, when the configuration is built; {LLM.reset_configuration!} builds
15
+ # it again. Writers override either, and a nil written restores the
16
+ # default.
17
+ #
18
+ # Invalid values raise {Errors::ConfigurationError} naming the variable
19
+ # (or the writer) that carried them, at the moment they are read, so a
20
+ # typo in INQUIREX_LLM_ADAPTER fails loudly instead of silently falling
21
+ # through to auto-detection.
22
+ #
23
+ # @example
24
+ # Inquirex::LLM.configure do |c|
25
+ # c.provider = :anthropic
26
+ # c.anthropic_model = "claude-opus-5-5"
27
+ # c.max_tokens = 4096
28
+ # end
29
+ class Configuration
30
+ # The providers {LLM.adapter} can build. nil means auto-detect.
31
+ PROVIDERS = %i[anthropic openai openrouter jev null].freeze
32
+
33
+ DEFAULT_MAX_TOKENS = 2048
34
+ DEFAULT_TEMPERATURE = 0.2
35
+
36
+ # Summaries want more room than an extraction (prose, not a handful of
37
+ # fields) and more warmth than one (0.2 produces summaries that read
38
+ # like a database dump).
39
+ DEFAULT_SUMMARY_MAX_TOKENS = 4096
40
+ DEFAULT_SUMMARY_TEMPERATURE = 0.4
41
+
42
+ # The range both providers accept; OpenAI's is the wider of the two.
43
+ TEMPERATURES = (0.0..2.0)
44
+
45
+ # What {#to_h} and {#inspect} show instead of a credential.
46
+ REDACTED = "[REDACTED]"
47
+
48
+ # One setting: the variable it is read from, its default, and the
49
+ # coercion its value goes through.
50
+ Setting = Data.define(:env, :default, :type)
51
+
52
+ # Every setting, in the order {#to_h} lists them. Readers, writers,
53
+ # the ENV table in the README and the specs all follow this table.
54
+ SETTINGS = {
55
+ provider: Setting.new("INQUIREX_LLM_ADAPTER", nil, :provider),
56
+ anthropic_api_key: Setting.new("ANTHROPIC_API_KEY", nil, :string),
57
+ openai_api_key: Setting.new("OPENAI_API_KEY", nil, :string),
58
+ openrouter_api_key: Setting.new("OPENROUTER_API_KEY", nil, :string),
59
+ typesafe_api_key: Setting.new("TYPESAFE_API_KEY", nil, :string),
60
+ anthropic_model: Setting.new("INQUIREX_LLM_ANTHROPIC_MODEL", Models::ANTHROPIC_DEFAULT, :string),
61
+ openai_model: Setting.new("INQUIREX_LLM_OPENAI_MODEL", Models::OPENAI_DEFAULT, :string),
62
+ openrouter_model: Setting.new("INQUIREX_LLM_OPENROUTER_MODEL", Models::OPENROUTER_DEFAULT, :string),
63
+ jev_model: Setting.new("INQUIREX_LLM_JEV_MODEL", Models::JEV_DEFAULT, :string),
64
+ max_tokens: Setting.new("INQUIREX_LLM_MAX_TOKENS", DEFAULT_MAX_TOKENS, :positive_integer),
65
+ temperature: Setting.new("INQUIREX_LLM_TEMPERATURE", DEFAULT_TEMPERATURE, :temperature),
66
+ summary_max_tokens: Setting.new("INQUIREX_LLM_SUMMARY_MAX_TOKENS", DEFAULT_SUMMARY_MAX_TOKENS, :positive_integer),
67
+ summary_temperature: Setting.new("INQUIREX_LLM_SUMMARY_TEMPERATURE", DEFAULT_SUMMARY_TEMPERATURE, :temperature),
68
+ debug: Setting.new("INQUIREX_DEBUG", false, :flag)
69
+ }.freeze
70
+
71
+ SETTINGS.each_key do |name|
72
+ attr_reader name
73
+
74
+ # @param value [Object, nil] nil restores the default
75
+ # @raise [Errors::ConfigurationError] when the value is invalid
76
+ define_method(:"#{name}=") do |value|
77
+ instance_variable_set(:"@#{name}", resolve(name, value, "Inquirex::LLM.configuration.#{name}"))
78
+ end
79
+ end
80
+
81
+ # @param env [#[]] the environment to read; ENV unless a spec says otherwise
82
+ # @raise [Errors::ConfigurationError] when a variable holds an invalid value
83
+ def initialize(env = ENV)
84
+ SETTINGS.each do |name, setting|
85
+ instance_variable_set(:"@#{name}", resolve(name, env[setting.env], setting.env))
86
+ end
87
+ end
88
+
89
+ # @return [Boolean] whether debug logging is on
90
+ def debug? = debug
91
+
92
+ # Every setting, with credentials replaced by {REDACTED} when set.
93
+ #
94
+ # @return [Hash{Symbol => Object}]
95
+ def to_h
96
+ SETTINGS.keys.to_h do |name|
97
+ value = public_send(name)
98
+ [name, secret?(name) && value ? REDACTED : value]
99
+ end
100
+ end
101
+
102
+ # Never shows a credential: an exception report or a console session
103
+ # that prints the configuration must not leak a key.
104
+ #
105
+ # @return [String]
106
+ def inspect
107
+ "#<#{self.class.name} #{to_h.map { |name, value| "#{name}=#{value.inspect}" }.join(", ")}>"
108
+ end
109
+ alias to_s inspect
110
+
111
+ private
112
+
113
+ def secret?(name) = name.end_with?("_api_key")
114
+
115
+ # @param name [Symbol] the setting
116
+ # @param value [Object, nil] raw value from ENV or a writer
117
+ # @param source [String] what to name in an error
118
+ # @return [Object] the coerced value, or the default when blank
119
+ def resolve(name, value, source)
120
+ setting = SETTINGS.fetch(name)
121
+ return setting.default if value.nil? || (value.is_a?(String) && value.strip.empty?)
122
+
123
+ send(:"coerce_#{setting.type}", value, source)
124
+ end
125
+
126
+ def coerce_string(value, _source) = value.to_s.strip
127
+
128
+ def coerce_provider(value, source)
129
+ provider = value.to_s.strip.downcase.to_sym
130
+ return provider if PROVIDERS.include?(provider)
131
+
132
+ invalid!(source, value, "one of #{PROVIDERS.join(", ")}")
133
+ end
134
+
135
+ def coerce_positive_integer(value, source)
136
+ integer = Integer(value.to_s.strip, 10)
137
+ integer.positive? ? integer : invalid!(source, value, "a positive integer")
138
+ rescue ArgumentError, TypeError
139
+ invalid!(source, value, "a positive integer")
140
+ end
141
+
142
+ def coerce_temperature(value, source)
143
+ float = Float(value.to_s.strip)
144
+ TEMPERATURES.cover?(float) ? float : invalid!(source, value, "a number from #{TEMPERATURES.min} to #{TEMPERATURES.max}")
145
+ rescue ArgumentError, TypeError
146
+ invalid!(source, value, "a number from #{TEMPERATURES.min} to #{TEMPERATURES.max}")
147
+ end
148
+
149
+ # Any non-blank value turns debugging on, as INQUIREX_DEBUG always has.
150
+ def coerce_flag(value, _source) = value.is_a?(String) || value == true
151
+
152
+ def invalid!(source, value, expected)
153
+ raise Errors::ConfigurationError, "#{source} must be #{expected} (got #{value.inspect})"
154
+ end
155
+ end
156
+
157
+ class << self
158
+ # The gem-wide configuration, built from ENV on first use.
159
+ #
160
+ # @return [Configuration]
161
+ def configuration
162
+ @configuration ||= Configuration.new
163
+ end
164
+
165
+ # @yieldparam config [Configuration]
166
+ # @return [Configuration]
167
+ def configure
168
+ yield configuration
169
+ configuration
170
+ end
171
+
172
+ # Discards every write and reads ENV again. Call it after changing ENV
173
+ # (a .env file loaded late, or a spec), since ENV is read only once.
174
+ #
175
+ # @return [Configuration]
176
+ def reset_configuration!
177
+ @configuration = Configuration.new
178
+ end
179
+ end
180
+ end
181
+ end
@@ -14,6 +14,10 @@ module Inquirex
14
14
 
15
15
  # Raised when the LLM adapter call fails after exhausting retries.
16
16
  class AdapterError < Error; end
17
+
18
+ # Raised when a configuration value, from ENV or a writer, is invalid.
19
+ # The message names the variable or attribute that carried it.
20
+ class ConfigurationError < Error; end
17
21
  end
18
22
  end
19
23
  end