little_ghost 0.10.0 → 0.11.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/README.md +5 -1
- data/docs/guides/decisions.md +69 -0
- data/docs/guides/embeddings.md +23 -0
- data/docs/guides/models_and_providers.md +10 -29
- data/docs/guides/providers.md +58 -11
- data/lib/little_ghost/assembly_builder.rb +2 -2
- data/lib/little_ghost/code_mode/javascript/client.rb +1 -1
- data/lib/little_ghost/code_mode/session.rb +3 -0
- data/lib/little_ghost/decision.rb +230 -0
- data/lib/little_ghost/model.rb +5 -0
- data/lib/little_ghost/model_operations.rb +14 -0
- data/lib/little_ghost/provider_registry.rb +1 -0
- data/lib/little_ghost/providers/base.rb +5 -0
- data/lib/little_ghost/providers/decision_http.rb +91 -0
- data/lib/little_ghost/providers/gemini.rb +34 -8
- data/lib/little_ghost/providers/open_router.rb +18 -1
- data/lib/little_ghost/providers/typesafe.rb +35 -0
- data/lib/little_ghost/runtime.rb +5 -0
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7758c8ce1e1ee404d86fe7dd76065dbd983a0abb660cedd82f7a6ffac0a3509
|
|
4
|
+
data.tar.gz: 485a32b90c723ed4a3c8b064151495e92d8679527f408d39e91145c2f3aaea09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb412e0ec1ff688067bdbb14d34e62a72713f617a4d487188ca40ef2d05c2b3c18262b6b9da1e286a977f8251fb65b4818078d4a044c2d9566b1836876634b6e
|
|
7
|
+
data.tar.gz: c79396a4608f4643415a0ac50ca30f23e7a26cc5688a8ac593bf6674e7d8005185068c6e6107f205d53c834289a4aac390350f3e4e5deffea72d57ed76bddb19
|
data/README.md
CHANGED
|
@@ -73,7 +73,9 @@ The source checkout also includes a complete
|
|
|
73
73
|
[single-file Agent](https://github.com/littleghostai/little_ghost/tree/main/examples/basic_agent)
|
|
74
74
|
and
|
|
75
75
|
[coding harness](https://github.com/littleghostai/little_ghost/tree/main/examples/coding_harness),
|
|
76
|
-
both configured for local Ollama.
|
|
76
|
+
both configured for local Ollama. The
|
|
77
|
+
[decision example](https://github.com/littleghostai/little_ghost/tree/main/examples/decision)
|
|
78
|
+
shows typed questions with Jev through OpenRouter.
|
|
77
79
|
|
|
78
80
|
## Give an agent real capabilities
|
|
79
81
|
|
|
@@ -160,6 +162,8 @@ upgrading, because interfaces may change between releases.
|
|
|
160
162
|
- [Structured Results and Content](docs/guides/structured_outputs_and_content.md) covers checked result shapes, images, and documents.
|
|
161
163
|
- [Compose Agents](docs/guides/assemblies.md) walks through workflows, swarms, graphs, nesting, and builders.
|
|
162
164
|
- [Skills](docs/guides/skills.md) organizes reusable instructions and supporting resources.
|
|
165
|
+
- [Decisions](docs/guides/decisions.md) explains typed questions and structured answers.
|
|
166
|
+
- [Embeddings](docs/guides/embeddings.md) explains text representations for similarity-based features.
|
|
163
167
|
- [Workspaces and Sandboxes](docs/guides/sandboxing.md) gives files and child processes a deliberate place to run.
|
|
164
168
|
- [Code Mode](docs/guides/code_mode.md) lets a model compose Tools in sandboxed Ruby or optional JavaScript.
|
|
165
169
|
- [Integrations](docs/guides/integrations.md) connects Run streams to AG-UI and OpenTelemetry.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Decisions
|
|
2
|
+
|
|
3
|
+
Use a decision model when an application needs typed answers to a small set of
|
|
4
|
+
questions. `LittleGhost.decide` returns the answers and token usage without
|
|
5
|
+
starting an Agent or creating a Run. Jev supports Choice, Score, and Noul
|
|
6
|
+
(yes/no probability) questions:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
result = LittleGhost.decide(
|
|
10
|
+
model: "primary:~typesafe/jev-latest",
|
|
11
|
+
state: {payout_status: "failed", failed_days: 3},
|
|
12
|
+
questions: [
|
|
13
|
+
{
|
|
14
|
+
id: "route",
|
|
15
|
+
type: :choice,
|
|
16
|
+
instructions: "Choose a route",
|
|
17
|
+
criteria: {
|
|
18
|
+
review: "Needs more investigation",
|
|
19
|
+
approve: "Meets the criteria",
|
|
20
|
+
decline: "Does not meet the criteria"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{id: "urgent", type: :noul, instructions: "Does this need same-day attention?"}
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
result.answers.fetch("route").choice
|
|
28
|
+
result.answers.fetch("urgent").noul
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Choice answers contain the selected label. Noul answers contain the
|
|
32
|
+
probability of yes from 0 to 1. Score answers contain a probability-weighted
|
|
33
|
+
value across the declared levels. Choice and Score answers also include the
|
|
34
|
+
probability distribution and confidence; Score answers include the level
|
|
35
|
+
legend.
|
|
36
|
+
|
|
37
|
+
For reusable decisions, declare questions once on a `Decision` class. A class
|
|
38
|
+
can mix question types:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class ApplicationTriage < LittleGhost::Decision
|
|
42
|
+
model "primary:~typesafe/jev-latest"
|
|
43
|
+
choice :route, instructions: "Choose a route", criteria: {
|
|
44
|
+
review: "Needs more investigation",
|
|
45
|
+
approve: "Meets the criteria",
|
|
46
|
+
decline: "Does not meet the criteria"
|
|
47
|
+
}
|
|
48
|
+
noul :urgent, instructions: "Does this need same-day attention?"
|
|
49
|
+
score :quality, instructions: "Rate the quality", criteria: ["accuracy", "completeness"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
application_state = {payout_status: "failed", failed_days: 3}
|
|
53
|
+
result = ApplicationTriage.ask(application_state)
|
|
54
|
+
result.answers.fetch("quality").score
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The class-level `.ask` and an instance's `#ask` return
|
|
58
|
+
`LittleGhost::DecisionResult`. Read a typed value with
|
|
59
|
+
`LittleGhost::DecisionAnswer#value` or its type-specific reader (`choice`,
|
|
60
|
+
`noul`, or `score`).
|
|
61
|
+
|
|
62
|
+
The selected provider receives the state and question text. Choose a provider
|
|
63
|
+
that is appropriate for that data.
|
|
64
|
+
|
|
65
|
+
Decisions require a TypeSafe connection or an OpenRouter connection configured
|
|
66
|
+
for a Jev decision endpoint. Other providers raise
|
|
67
|
+
`UnsupportedModelOperationError`. See [Provider Support](providers.md) for
|
|
68
|
+
connection setup and [Models and Providers](models_and_providers.md) for model
|
|
69
|
+
targets, shared roles, and capabilities.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Embeddings
|
|
2
|
+
|
|
3
|
+
Use `LittleGhost.embed` to turn text into numeric representations for search,
|
|
4
|
+
clustering, or another similarity-based feature:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
response = LittleGhost.embed(
|
|
8
|
+
model: "primary:openai/text-embedding-3-small",
|
|
9
|
+
inputs: ["Reset a password", "Track a transfer"]
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
response.vectors.length # => 2
|
|
13
|
+
response.dimensions
|
|
14
|
+
response.usage.input_tokens
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The response keeps vectors in the same order as the inputs. LittleGhost rejects
|
|
18
|
+
an incomplete or malformed response instead of returning a partial batch.
|
|
19
|
+
Embedding text is sent to the selected provider, so choose one appropriate for
|
|
20
|
+
that data. [Provider Support](providers.md) identifies adapters with embedding
|
|
21
|
+
support; `LittleGhost::Embeddings::Request` documents settings and request
|
|
22
|
+
bounds. See [Models and Providers](models_and_providers.md) for model targets,
|
|
23
|
+
shared roles, and capabilities.
|
|
@@ -74,9 +74,10 @@ application obtains short-lived credentials at runtime, configure a credential
|
|
|
74
74
|
resolver that returns them for the selected connection.
|
|
75
75
|
|
|
76
76
|
> **Safety note:** The selected provider may receive system instructions,
|
|
77
|
-
> caller input,
|
|
78
|
-
>
|
|
79
|
-
> provider endpoints under
|
|
77
|
+
> caller input, application state, question text, conversation
|
|
78
|
+
> history, Tool results, schemas, and attachments. Choose a provider that is
|
|
79
|
+
> appropriate for that data, and keep credentials and provider endpoints under
|
|
80
|
+
> application control.
|
|
80
81
|
|
|
81
82
|
## Choose a role for each request
|
|
82
83
|
|
|
@@ -155,29 +156,9 @@ Results and Content](structured_outputs_and_content.md) covers strict schemas,
|
|
|
155
156
|
provider strategies, repair behavior, and the application checks that still
|
|
156
157
|
belong outside the schema.
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
response = LittleGhost.embed(
|
|
165
|
-
model: "primary:openai/text-embedding-3-small",
|
|
166
|
-
inputs: ["Reset a password", "Track a transfer"]
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
response.vectors.length # => 2
|
|
170
|
-
response.dimensions
|
|
171
|
-
response.usage.input_tokens
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
The response keeps vectors in the same order as the inputs. LittleGhost rejects
|
|
175
|
-
an incomplete or malformed response instead of returning a partial batch.
|
|
176
|
-
Embedding text is sent to the selected provider, so choose one appropriate for
|
|
177
|
-
that data. [Provider Support](providers.md) identifies adapters with embedding
|
|
178
|
-
support; `LittleGhost::Embeddings::Request` documents settings and request
|
|
179
|
-
bounds.
|
|
180
|
-
|
|
181
|
-
Continue with [Prompts as Views](prompt_views.md) when an Agent's instructions
|
|
182
|
-
outgrow one string. See [Structured Results and Content](structured_outputs_and_content.md)
|
|
183
|
-
when you need checked result shapes, images, or documents.
|
|
159
|
+
Continue with [Decisions](decisions.md) for typed, structured answers or
|
|
160
|
+
[Embeddings](embeddings.md) for text representations. [Prompts as
|
|
161
|
+
Views](prompt_views.md) moves growing Agent instructions into conventional ERB
|
|
162
|
+
files. See [Structured Results and
|
|
163
|
+
Content](structured_outputs_and_content.md) for checked result shapes, images,
|
|
164
|
+
or documents.
|
data/docs/guides/providers.md
CHANGED
|
@@ -23,17 +23,24 @@ end
|
|
|
23
23
|
protocol. An Agent can now use a target such as
|
|
24
24
|
`primary:openai/gpt-5.6-luna`.
|
|
25
25
|
|
|
26
|
-
| Provider or endpoint | Adapter | Generation | Embeddings |
|
|
27
|
-
| --- | --- | --- | --- |
|
|
28
|
-
| OpenRouter | `:openrouter` | Chat Completions | When the model supports it |
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
26
|
+
| Provider or endpoint | Adapter | Generation | Decisions | Embeddings |
|
|
27
|
+
| --- | --- | --- | --- | --- |
|
|
28
|
+
| OpenRouter | `:openrouter` | Chat Completions | Typed decision endpoints | When the model supports it |
|
|
29
|
+
| TypeSafe | `:typesafe` | No | System One decisions | No |
|
|
30
|
+
| OpenAI | `:openai` | Responses or Chat Completions | No | Yes |
|
|
31
|
+
| Compatible API | `:openai_compatible` | Responses or Chat Completions | No | When the endpoint implements it |
|
|
32
|
+
| Ollama | `:openai_compatible` | Responses or Chat Completions | No | When the model supports it |
|
|
33
|
+
| Anthropic | `:anthropic` | Messages | No | No |
|
|
34
|
+
| Gemini | `:gemini` | Gemini API | No | No |
|
|
35
|
+
| Vertex AI | `:vertex_ai` | Gemini on Vertex AI | No | No |
|
|
36
|
+
| Bedrock | `:bedrock` | Converse | No | Titan Text Embeddings V2 |
|
|
37
|
+
| LM Studio | `:lm_studio` | Responses or Chat Completions | No | Yes |
|
|
38
|
+
|
|
39
|
+
TypeSafe Jev is a decision model that returns structured answers to typed
|
|
40
|
+
questions. Its API and OpenRouter's Jev routes support the same question
|
|
41
|
+
types.
|
|
42
|
+
These operations return structured answers directly and do not create an Agent
|
|
43
|
+
Run. See [Decisions](decisions.md) for the question and answer model.
|
|
37
44
|
|
|
38
45
|
Capabilities can vary by model, account, and server version. Handle provider
|
|
39
46
|
failures even when a model is expected to support a feature.
|
|
@@ -60,6 +67,46 @@ Use OpenRouter's `publisher/model` form, such as
|
|
|
60
67
|
The selected model must support embeddings before you call
|
|
61
68
|
`LittleGhost.embed`.
|
|
62
69
|
|
|
70
|
+
For Jev decisions, OpenRouter supports both decision endpoints. The default
|
|
71
|
+
uses the Decisions API and namespaced model identifiers such as
|
|
72
|
+
`openrouter:~typesafe/jev-latest`:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
LittleGhost.configure do |config|
|
|
76
|
+
config.providers = {
|
|
77
|
+
openrouter: {
|
|
78
|
+
adapter: :openrouter,
|
|
79
|
+
api_key: ENV.fetch("OPENROUTER_API_KEY"),
|
|
80
|
+
decision_api: :decisions
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Set `decision_api: :system_one` to use `/api/v1/systemone`; that route
|
|
87
|
+
accepts a bare TypeSafe model identifier such as `jev-latest`, used as
|
|
88
|
+
`openrouter:jev-latest`.
|
|
89
|
+
OpenRouter describes these routes in its [Jev guide](https://openrouter.ai/blog/insights/what-is-jev/).
|
|
90
|
+
|
|
91
|
+
### TypeSafe
|
|
92
|
+
|
|
93
|
+
Connect directly to TypeSafe's System One endpoint with a TypeSafe API key:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
LittleGhost.configure do |config|
|
|
97
|
+
config.providers = {
|
|
98
|
+
typesafe: {
|
|
99
|
+
adapter: :typesafe,
|
|
100
|
+
api_key: ENV.fetch("TYPESAFE_API_KEY")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Use a Jev identifier such as `typesafe:jev-latest`. This adapter supports
|
|
107
|
+
typed decisions only. See the [TypeSafe API reference](https://docs.typesafe.ai/api)
|
|
108
|
+
for question and answer fields.
|
|
109
|
+
|
|
63
110
|
### OpenAI
|
|
64
111
|
|
|
65
112
|
```ruby
|
|
@@ -176,7 +176,7 @@ module LittleGhost
|
|
|
176
176
|
value = implementation.instance_variable_get(name)
|
|
177
177
|
implementation.instance_variable_set(name, deep_freeze_snapshot_value(value))
|
|
178
178
|
end
|
|
179
|
-
implementation.methods.grep(/
|
|
179
|
+
implementation.methods.grep(/_values?\z/).each do |reader|
|
|
180
180
|
writer = :"#{reader}="
|
|
181
181
|
next unless implementation.respond_to?(writer)
|
|
182
182
|
|
|
@@ -232,7 +232,7 @@ module LittleGhost
|
|
|
232
232
|
value.instance_variables.each do |name|
|
|
233
233
|
snapshot.instance_variable_set(name, Support.deep_dup(value.instance_variable_get(name)))
|
|
234
234
|
end
|
|
235
|
-
value.methods.grep(/
|
|
235
|
+
value.methods.grep(/_values?\z/).each do |reader|
|
|
236
236
|
writer = :"#{reader}="
|
|
237
237
|
next unless snapshot.respond_to?(writer)
|
|
238
238
|
|
|
@@ -222,7 +222,7 @@ module LittleGhost
|
|
|
222
222
|
def observe(owner:, program_id:, timeout:, max_tokens:, context: nil)
|
|
223
223
|
program = owned_program(owner, program_id)
|
|
224
224
|
result = program.observe(timeout:, max_tokens:, context:)
|
|
225
|
-
release_program(program)
|
|
225
|
+
release_program(program) unless result.fetch(:status) == "still_working"
|
|
226
226
|
result
|
|
227
227
|
end
|
|
228
228
|
|
|
@@ -16,6 +16,9 @@ module LittleGhost
|
|
|
16
16
|
# +:still_working+ when an observation interval elapsed, +:terminated+ after
|
|
17
17
|
# requested termination, and +:error+ for a model-program error. +wait+
|
|
18
18
|
# observes an active program without pausing, resuming, or restarting it.
|
|
19
|
+
# A +:still_working+ result keeps the program available for another observation,
|
|
20
|
+
# even if it finishes immediately afterward. Collect output from every result;
|
|
21
|
+
# the terminal result contains only output not returned by earlier observations.
|
|
19
22
|
# Lifecycle, host, and cleanup failures raise rather than becoming a result.
|
|
20
23
|
#
|
|
21
24
|
# ProgramResult.new(
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Provider-neutral inputs for one typed decision operation.
|
|
5
|
+
#
|
|
6
|
+
# State and questions are trusted application inputs. Question ids are
|
|
7
|
+
# normalized to strings and must be unique.
|
|
8
|
+
DecisionRequest = Data.define(:state, :questions, :cancellation_token, :deadline) do # :nodoc:
|
|
9
|
+
def initialize(state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil)
|
|
10
|
+
unless state.is_a?(String) || state.is_a?(Hash) || state.is_a?(Array)
|
|
11
|
+
raise ArgumentError, "decision state must be a string, mapping, or array"
|
|
12
|
+
end
|
|
13
|
+
entries = questions.is_a?(Hash) ? questions.map { |id, value| value.to_h.merge(id:) } : Array(questions)
|
|
14
|
+
normalized = entries.map do |question|
|
|
15
|
+
raise ArgumentError, "decision questions must be mappings" unless question.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
item = question.to_h.transform_keys(&:to_sym)
|
|
18
|
+
type = item[:type]&.to_sym
|
|
19
|
+
raise ArgumentError, "decision question type must be :choice, :noul, or :score" unless %i[choice noul score].include?(type)
|
|
20
|
+
item[:type] = type
|
|
21
|
+
item[:id] = item[:id].to_s
|
|
22
|
+
raise ArgumentError, "decision question id is required" if item[:id].empty?
|
|
23
|
+
unless item[:instructions].is_a?(String) || item[:instructions].is_a?(Hash) || item[:instructions].is_a?(Array)
|
|
24
|
+
raise ArgumentError, "decision question instructions are required"
|
|
25
|
+
end
|
|
26
|
+
if type == :choice
|
|
27
|
+
criteria = item[:criteria]
|
|
28
|
+
raise ArgumentError, "choice criteria must be a mapping or array" unless criteria.is_a?(Hash) || criteria.is_a?(Array)
|
|
29
|
+
raise ArgumentError, "choice criteria must contain between one and 255 options" unless (1..255).cover?(criteria.length)
|
|
30
|
+
if criteria.is_a?(Array)
|
|
31
|
+
labels = criteria.map(&:to_s)
|
|
32
|
+
raise ArgumentError, "choice criteria labels must be unique" unless labels.uniq.length == labels.length
|
|
33
|
+
|
|
34
|
+
item[:criteria] = labels.to_h { |label| [label, nil] }
|
|
35
|
+
else
|
|
36
|
+
labels = criteria.keys.map(&:to_s)
|
|
37
|
+
raise ArgumentError, "choice criteria labels must be unique" unless labels.uniq.length == labels.length
|
|
38
|
+
|
|
39
|
+
valid_criteria = criteria.all? do |label, description|
|
|
40
|
+
valid_label = label.is_a?(String) || label.is_a?(Symbol)
|
|
41
|
+
valid_description = description.nil? || description.is_a?(String) ||
|
|
42
|
+
description.is_a?(Hash) || description.is_a?(Array)
|
|
43
|
+
valid_label && valid_description
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "choice criteria must map labels to descriptions" unless valid_criteria
|
|
46
|
+
end
|
|
47
|
+
elsif type == :score
|
|
48
|
+
criteria = item[:criteria]
|
|
49
|
+
raise ArgumentError, "score criteria must contain between two and ten levels" unless criteria.is_a?(Array) && (2..10).cover?(criteria.length)
|
|
50
|
+
valid_levels = criteria.all? { |level| level.is_a?(String) || level.is_a?(Hash) || level.is_a?(Array) }
|
|
51
|
+
raise ArgumentError, "score criteria must be descriptions" unless valid_levels
|
|
52
|
+
elsif !item[:criteria].nil?
|
|
53
|
+
criteria = item[:criteria]
|
|
54
|
+
valid_criteria = criteria.is_a?(Hash) && criteria.all? do |key, description|
|
|
55
|
+
%w[true false].include?(key.to_s) &&
|
|
56
|
+
(description.is_a?(String) || description.is_a?(Hash) || description.is_a?(Array))
|
|
57
|
+
end
|
|
58
|
+
raise ArgumentError, "noul criteria must map true or false to descriptions" unless valid_criteria
|
|
59
|
+
end
|
|
60
|
+
item.freeze
|
|
61
|
+
end
|
|
62
|
+
raise ArgumentError, "at least one decision question is required" if normalized.empty?
|
|
63
|
+
ids = normalized.map { |item| item[:id] }
|
|
64
|
+
raise ArgumentError, "decision question ids must be unique" unless ids.uniq.length == ids.length
|
|
65
|
+
|
|
66
|
+
super(state:, questions: normalized.freeze, cancellation_token:, deadline:)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Carries the state, questions, and request controls passed to a decision
|
|
71
|
+
# provider.
|
|
72
|
+
class DecisionRequest < Data # :doc:
|
|
73
|
+
##
|
|
74
|
+
# :attr_reader: state
|
|
75
|
+
# Application state evaluated by the provider.
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# :attr_reader: questions
|
|
79
|
+
# Frozen question declarations, each with a string +id+.
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# :attr_reader: cancellation_token
|
|
83
|
+
# Token the provider checks while the request runs.
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# :attr_reader: deadline
|
|
87
|
+
# Optional request deadline passed to the provider.
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# :singleton-method: new
|
|
91
|
+
# :call-seq:
|
|
92
|
+
# new(state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil) -> DecisionRequest
|
|
93
|
+
#
|
|
94
|
+
# Validates and freezes question declarations before a provider request.
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A validated, provider-neutral answer to a typed decision question.
|
|
98
|
+
#
|
|
99
|
+
# +value+ contains the choice label, a numeric noul probability from 0 to 1,
|
|
100
|
+
# or a numeric score according to +type+. The type-specific readers return
|
|
101
|
+
# +nil+ for other answer types.
|
|
102
|
+
DecisionAnswer = Data.define(:type, :value, :probabilities, :confidence, :legend) do # :nodoc:
|
|
103
|
+
def choice
|
|
104
|
+
value if type == :choice
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def noul
|
|
108
|
+
value if type == :noul
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def score
|
|
112
|
+
value if type == :score
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One typed answer returned by a decision provider.
|
|
117
|
+
class DecisionAnswer < Data # :doc:
|
|
118
|
+
##
|
|
119
|
+
# :attr_reader: type
|
|
120
|
+
# Answer type: +:choice+, +:noul+, or +:score+.
|
|
121
|
+
|
|
122
|
+
##
|
|
123
|
+
# :attr_reader: value
|
|
124
|
+
# Selected label, yes probability, or probability-weighted score.
|
|
125
|
+
|
|
126
|
+
##
|
|
127
|
+
# :attr_reader: probabilities
|
|
128
|
+
# Option or level probabilities for Choice and Score answers.
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# :attr_reader: confidence
|
|
132
|
+
# Confidence value for Choice and Score answers.
|
|
133
|
+
|
|
134
|
+
##
|
|
135
|
+
# :attr_reader: legend
|
|
136
|
+
# Level descriptions for Score answers.
|
|
137
|
+
|
|
138
|
+
##
|
|
139
|
+
# :method: choice
|
|
140
|
+
# Returns the selected label for a Choice answer, or +nil+ for another type.
|
|
141
|
+
|
|
142
|
+
##
|
|
143
|
+
# :method: noul
|
|
144
|
+
# Returns the yes probability from 0 to 1 for a Noul answer, or +nil+ for another type.
|
|
145
|
+
|
|
146
|
+
##
|
|
147
|
+
# :method: score
|
|
148
|
+
# Returns the probability-weighted value for a Score answer, or +nil+ for another type.
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Results returned by a decision model, keyed by question id.
|
|
152
|
+
#
|
|
153
|
+
# Provider metadata contains bounded response identifiers, model details,
|
|
154
|
+
# and reported cost when available; it excludes request state and question text.
|
|
155
|
+
DecisionResult = Data.define(:answers, :usage, :metadata) do # :nodoc:
|
|
156
|
+
def initialize(answers:, usage: Usage.new, metadata: {})
|
|
157
|
+
super(answers: answers.to_h.freeze, usage:, metadata: metadata.to_h.freeze)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Carries validated answers and token usage from one decision call.
|
|
162
|
+
class DecisionResult < Data # :doc:
|
|
163
|
+
##
|
|
164
|
+
# :attr_reader: answers
|
|
165
|
+
# Frozen question-id to DecisionAnswer mapping.
|
|
166
|
+
|
|
167
|
+
##
|
|
168
|
+
# :attr_reader: usage
|
|
169
|
+
# Normalized token usage for the provider request.
|
|
170
|
+
|
|
171
|
+
##
|
|
172
|
+
# :attr_reader: metadata
|
|
173
|
+
# Frozen provider metadata such as model, request id, provider, and cost.
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Declarative collection of typed questions for one decision request.
|
|
177
|
+
#
|
|
178
|
+
# Subclass and declare a model plus one or more typed questions, then call
|
|
179
|
+
# +.ask(state)+ or instantiate the class and call +#ask(state)+.
|
|
180
|
+
class Decision
|
|
181
|
+
class << self
|
|
182
|
+
def inherited(child) # :nodoc:
|
|
183
|
+
child.instance_variable_set(:@questions, questions.map(&:dup))
|
|
184
|
+
child.instance_variable_set(:@model, model)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Selects a direct target or configured model role for this decision.
|
|
188
|
+
def model(value = :__read__)
|
|
189
|
+
return @model if value == :__read__
|
|
190
|
+
|
|
191
|
+
@model = value
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Declares a choice question. Criteria may be an array of labels or a
|
|
195
|
+
# mapping from labels to their descriptions.
|
|
196
|
+
def choice(id, instructions:, criteria:, **options) = add_question(:choice, id, instructions:, criteria:, **options)
|
|
197
|
+
# Declares a yes/no (noul) question, optionally with decision criteria.
|
|
198
|
+
def noul(id, instructions:, criteria: nil, **options) = add_question(:noul, id, instructions:, criteria:, **options)
|
|
199
|
+
# Declares a score question with ordered scoring criteria.
|
|
200
|
+
def score(id, instructions:, criteria:, **options) = add_question(:score, id, instructions:, criteria:, **options)
|
|
201
|
+
|
|
202
|
+
# Runs all declared questions against +state+.
|
|
203
|
+
def ask(state, **options)
|
|
204
|
+
LittleGhost.decide(model: model || raise(ConfigurationError, "Decision model is required"), state:, questions:, **options)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Returns the immutable question declarations for this class.
|
|
208
|
+
def questions = (@questions ||= []).map(&:dup).freeze
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def add_question(type, id, **attributes)
|
|
213
|
+
raise ArgumentError, "question id is required" if id.to_s.empty?
|
|
214
|
+
raise ArgumentError, "duplicate decision question id: #{id}" if questions.any? { |question| question[:id] == id.to_s }
|
|
215
|
+
|
|
216
|
+
(@questions ||= []) << attributes.merge(type:, id: id.to_s).freeze
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Binds this decision instance to a Runtime.
|
|
221
|
+
def initialize(runtime: LittleGhost.runtime)
|
|
222
|
+
@runtime = runtime
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Runs this decision's questions against +state+.
|
|
226
|
+
def ask(state, **options)
|
|
227
|
+
@runtime.decide(model: self.class.model || raise(ConfigurationError, "Decision model is required"), state:, questions: self.class.questions, **options)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/little_ghost/model.rb
CHANGED
|
@@ -96,6 +96,11 @@ module LittleGhost
|
|
|
96
96
|
response
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
# Executes a typed decision through this model's provider.
|
|
100
|
+
def decide(request)
|
|
101
|
+
provider.decide(request)
|
|
102
|
+
end
|
|
103
|
+
|
|
99
104
|
# Uses advertised provider capabilities.
|
|
100
105
|
def capabilities
|
|
101
106
|
@capabilities ||= provider.capabilities(metadata: details.attributes)
|
|
@@ -89,6 +89,20 @@ module LittleGhost
|
|
|
89
89
|
raise
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
def decide(model:, state:, questions:, cancellation_token: Support::CancellationToken.new, deadline: nil)
|
|
93
|
+
request = DecisionRequest.new(state:, questions:, cancellation_token:, deadline:)
|
|
94
|
+
resolved = @model_resolver.resolve(model)
|
|
95
|
+
handle = Instrumentation.start(:decision, model_provider: resolved.target.provider, model_id: resolved.model_id, question_count: request.questions.length)
|
|
96
|
+
result = resolved.decide(request)
|
|
97
|
+
raise ProtocolError, "Decision provider returned an unexpected result" unless result.is_a?(DecisionResult)
|
|
98
|
+
|
|
99
|
+
handle.finish(outcome: :success, **usage_attributes(result.usage))
|
|
100
|
+
result
|
|
101
|
+
rescue => error
|
|
102
|
+
handle&.finish(outcome: :error, error_type: error.class.name) if handle&.active?
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
|
|
92
106
|
private
|
|
93
107
|
|
|
94
108
|
def complete(model, messages:, settings:, schema:, strategy:, repair:, invocation_paths:, cancellation_token:, deadline:)
|
|
@@ -14,6 +14,7 @@ module LittleGhost
|
|
|
14
14
|
"openai" => Providers::OpenAI,
|
|
15
15
|
"lm_studio" => Providers::LMStudio,
|
|
16
16
|
"openrouter" => Providers::OpenRouter,
|
|
17
|
+
"typesafe" => Providers::Typesafe,
|
|
17
18
|
"anthropic" => Providers::Anthropic,
|
|
18
19
|
"gemini" => Providers::Gemini,
|
|
19
20
|
"vertex_ai" => Providers::VertexAI,
|
|
@@ -47,6 +47,11 @@ module LittleGhost
|
|
|
47
47
|
raise UnsupportedModelOperationError, "#{self.class} does not support embeddings"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# Executes a typed decision request when the adapter supports it.
|
|
51
|
+
def decide(_request)
|
|
52
|
+
raise UnsupportedModelOperationError, "#{self.class} does not support decisions"
|
|
53
|
+
end
|
|
54
|
+
|
|
50
55
|
# Applies provider-specific capability constraints before streaming.
|
|
51
56
|
def prepare_request(request, capabilities:)
|
|
52
57
|
request
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
module Providers
|
|
8
|
+
module DecisionHTTP # :nodoc:
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def send_decision(request, endpoint:, model:, api_key:)
|
|
12
|
+
request.cancellation_token&.raise_if_cancelled!
|
|
13
|
+
base_url = @decision_base_url.to_s
|
|
14
|
+
base_url += "/" unless base_url.end_with?("/")
|
|
15
|
+
payload = {
|
|
16
|
+
model:,
|
|
17
|
+
state: request.state,
|
|
18
|
+
questions: request.questions.to_h do |question|
|
|
19
|
+
id = question.fetch(:id)
|
|
20
|
+
fields = question.except(:id).reject { |name, value| name == :criteria && value.nil? }
|
|
21
|
+
[id, fields]
|
|
22
|
+
end
|
|
23
|
+
}
|
|
24
|
+
body = (@decision_transport || @transport).request(
|
|
25
|
+
uri: URI.join(base_url, endpoint),
|
|
26
|
+
method: :post,
|
|
27
|
+
headers: {"Authorization" => "Bearer #{api_key}", "Content-Type" => "application/json"},
|
|
28
|
+
body: JSON.generate(payload),
|
|
29
|
+
cancellation_token: request.cancellation_token,
|
|
30
|
+
deadline: request.deadline,
|
|
31
|
+
label: "Decision request"
|
|
32
|
+
)
|
|
33
|
+
data = JSON.parse(body)
|
|
34
|
+
answers = data.fetch("answers")
|
|
35
|
+
expected_ids = request.questions.map { |question| question.fetch(:id) }.sort
|
|
36
|
+
unless answers.is_a?(Hash) && answers.keys.sort == expected_ids
|
|
37
|
+
raise ProtocolError, "Decision provider returned unexpected or incomplete answers"
|
|
38
|
+
end
|
|
39
|
+
normalized = answers.to_h do |id, answer|
|
|
40
|
+
question = request.questions.find { |item| item.fetch(:id) == id }
|
|
41
|
+
[id, normalize_answer(question, answer)]
|
|
42
|
+
end
|
|
43
|
+
usage_data = data["usage"] || {}
|
|
44
|
+
cost = usage_data["cost"] || data["cost"]
|
|
45
|
+
DecisionResult.new(
|
|
46
|
+
answers: normalized,
|
|
47
|
+
usage: Usage.new(input_tokens: usage_data["input_tokens"], output_tokens: usage_data["output_tokens"]),
|
|
48
|
+
metadata: {model: data["model"], id: data["id"], provider: data["provider"], cost:}
|
|
49
|
+
)
|
|
50
|
+
rescue JSON::ParserError, KeyError, TypeError => error
|
|
51
|
+
raise ProtocolError, "Decision provider returned an invalid response (#{error.class})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_answer(question, raw)
|
|
55
|
+
raise ProtocolError, "Decision provider returned an invalid answer" unless raw.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
field = question.fetch(:type).to_s
|
|
58
|
+
raise ProtocolError, "Decision provider returned an answer with the wrong type" unless raw["type"] == field
|
|
59
|
+
|
|
60
|
+
value = raw[field]
|
|
61
|
+
valid = case field
|
|
62
|
+
when "choice"
|
|
63
|
+
criteria = question[:criteria]
|
|
64
|
+
options = criteria.is_a?(Hash) ? criteria.keys : Array(criteria)
|
|
65
|
+
value.is_a?(String) && options.any? { |item| item.to_s == value }
|
|
66
|
+
when "noul" then value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
|
|
67
|
+
when "score" then value.is_a?(Numeric) && value.finite?
|
|
68
|
+
end
|
|
69
|
+
raise ProtocolError, "Decision provider returned an invalid #{field} answer" unless valid
|
|
70
|
+
|
|
71
|
+
if %w[choice score].include?(field)
|
|
72
|
+
confidence = raw["confidence"]
|
|
73
|
+
probabilities = raw["probabilities"]
|
|
74
|
+
unless confidence.is_a?(Numeric) && confidence.finite? && confidence.between?(0, 1) &&
|
|
75
|
+
probabilities.is_a?(Hash) && probabilities.values.all? { |probability| probability.is_a?(Numeric) && probability.finite? && probability.between?(0, 1) }
|
|
76
|
+
raise ProtocolError, "Decision provider returned invalid #{field} probabilities"
|
|
77
|
+
end
|
|
78
|
+
raise ProtocolError, "Decision provider returned an invalid score legend" if field == "score" && !raw["legend"].is_a?(Hash)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
DecisionAnswer.new(
|
|
82
|
+
type: question.fetch(:type),
|
|
83
|
+
value:,
|
|
84
|
+
probabilities: raw["probabilities"] || {},
|
|
85
|
+
confidence: raw["confidence"],
|
|
86
|
+
legend: raw["legend"]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "json"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "uri"
|
|
6
7
|
require_relative "../support/http_client"
|
|
7
8
|
require_relative "../support/sse_parser"
|
|
@@ -17,6 +18,18 @@ module LittleGhost
|
|
|
17
18
|
# LittleGhost StreamEvents. HTTP and response-shape failures become
|
|
18
19
|
# ProviderError subclasses. It uses the built-in HTTP client and does not
|
|
19
20
|
# require Google's SDK.
|
|
21
|
+
#
|
|
22
|
+
# Gemini attaches an opaque +thoughtSignature+ to a function-call response
|
|
23
|
+
# part and expects it echoed back on the next request's matching
|
|
24
|
+
# +functionCall+. The adapter preserves these opaque values in the response
|
|
25
|
+
# Message metadata under +gemini/thought_signatures+, keyed by tool-call ID.
|
|
26
|
+
# Preserve the full assistant Message, including this provider replay metadata,
|
|
27
|
+
# when storing or rebuilding conversation history. Signatures survive provider
|
|
28
|
+
# recreation and Message#without_reasoning and replay with their original calls.
|
|
29
|
+
#
|
|
30
|
+
# Tool results use the function name from their preceding ToolUse in the
|
|
31
|
+
# request history. Keep each tool call before its result when restoring history.
|
|
32
|
+
# Results without a preceding call retain their tool-use ID as the name.
|
|
20
33
|
class Gemini < Base
|
|
21
34
|
# Request policy supported by Gemini and Vertex AI HTTP clients.
|
|
22
35
|
def self.request_options = %i[max_response_bytes open_timeout read_timeout].freeze
|
|
@@ -75,7 +88,8 @@ module LittleGhost
|
|
|
75
88
|
|
|
76
89
|
def request_body(request)
|
|
77
90
|
system, messages = request.messages.partition { |message| message.role == :system }
|
|
78
|
-
|
|
91
|
+
tool_names = {}
|
|
92
|
+
body = {contents: messages.map { |message| google_message(message, tool_names) }}
|
|
79
93
|
body[:systemInstruction] = {parts: system.flat_map { |message| message.content.grep(Content::Text).map { |block| {text: block.text} } }} unless system.empty?
|
|
80
94
|
body[:tools] = [{functionDeclarations: request.tools.map { |tool| google_tool(tool) }}] unless request.tools.empty?
|
|
81
95
|
body[:toolConfig] = google_tool_choice(request.tool_choice) if request.tool_choice
|
|
@@ -92,23 +106,32 @@ module LittleGhost
|
|
|
92
106
|
{max_tokens: :maxOutputTokens, top_p: :topP, top_k: :topK, stop_sequences: :stopSequences}[key.to_sym] || key.to_sym
|
|
93
107
|
end
|
|
94
108
|
|
|
95
|
-
def google_message(message)
|
|
96
|
-
|
|
109
|
+
def google_message(message, tool_names)
|
|
110
|
+
signatures = message.metadata.dig(:gemini, :thought_signatures) || {}
|
|
111
|
+
{role: (message.role == :assistant) ? "model" : "user", parts: message.content.map { |block| google_content(block, signatures, tool_names) }}
|
|
97
112
|
end
|
|
98
113
|
|
|
99
|
-
def google_content(block)
|
|
114
|
+
def google_content(block, thought_signatures, tool_names)
|
|
100
115
|
case block
|
|
101
116
|
when Content::Text then {text: block.text}
|
|
102
117
|
when Content::Image, Content::Document
|
|
103
118
|
{inlineData: {mimeType: block.media_type, data: Base64.strict_encode64(block.data)}}
|
|
104
|
-
when Content::ToolUse
|
|
119
|
+
when Content::ToolUse
|
|
120
|
+
tool_names[block.id] = block.name
|
|
121
|
+
google_tool_use(block, thought_signatures)
|
|
105
122
|
when Content::ToolResult
|
|
106
|
-
{functionResponse: {id: block.tool_use_id, name: block.tool_use_id, response: {output: Array(block.content).join("\n")}}}
|
|
123
|
+
{functionResponse: {id: block.tool_use_id, name: tool_names.fetch(block.tool_use_id, block.tool_use_id), response: {output: Array(block.content).join("\n")}}}
|
|
107
124
|
when Content::Reasoning then {text: block.text, thought: true}
|
|
108
125
|
else raise ConfigurationError, "Unsupported Google content block: #{block.class}"
|
|
109
126
|
end
|
|
110
127
|
end
|
|
111
128
|
|
|
129
|
+
def google_tool_use(block, thought_signatures)
|
|
130
|
+
call = {id: block.id, name: block.name, args: block.input}
|
|
131
|
+
signature = thought_signatures[block.id]
|
|
132
|
+
signature ? {functionCall: call, thoughtSignature: signature} : {functionCall: call}
|
|
133
|
+
end
|
|
134
|
+
|
|
112
135
|
def google_tool(tool)
|
|
113
136
|
value = tool.is_a?(Hash) ? tool.transform_keys(&:to_sym) : {name: tool.name, description: tool.description, input_schema: tool.input_schema}
|
|
114
137
|
{name: value.fetch(:name), description: value[:description], parametersJsonSchema: value[:input_schema] || {}}
|
|
@@ -123,6 +146,7 @@ module LittleGhost
|
|
|
123
146
|
class Normalizer # :nodoc:
|
|
124
147
|
def initialize(model:)
|
|
125
148
|
@model = model
|
|
149
|
+
@thought_signatures = {}
|
|
126
150
|
@text = +""
|
|
127
151
|
@reasoning = +""
|
|
128
152
|
@tools = []
|
|
@@ -161,7 +185,8 @@ module LittleGhost
|
|
|
161
185
|
blocks << Content::Reasoning.new(text: @reasoning) unless @reasoning.empty?
|
|
162
186
|
blocks << Content::Text.new(text: @text) unless @text.empty?
|
|
163
187
|
blocks.concat(@tools)
|
|
164
|
-
|
|
188
|
+
metadata = @thought_signatures.empty? ? {} : {gemini: {thought_signatures: @thought_signatures}}
|
|
189
|
+
response = ModelResponse.new(message: Message.new(role: :assistant, content: blocks, metadata:),
|
|
165
190
|
stop_reason: @stop_reason, usage: @usage, metadata: {model: @model})
|
|
166
191
|
[StreamEvent.build(:message_stop, response:)]
|
|
167
192
|
end
|
|
@@ -172,8 +197,9 @@ module LittleGhost
|
|
|
172
197
|
if part["functionCall"]
|
|
173
198
|
call = part.fetch("functionCall")
|
|
174
199
|
index = @tools.length
|
|
175
|
-
tool = Content::ToolUse.new(id: call["id"] ||
|
|
200
|
+
tool = Content::ToolUse.new(id: call["id"] || SecureRandom.uuid, name: call.fetch("name"), input: call["args"] || {})
|
|
176
201
|
@tools << tool
|
|
202
|
+
@thought_signatures[tool.id] = part["thoughtSignature"] if part["thoughtSignature"]
|
|
177
203
|
[
|
|
178
204
|
StreamEvent.build(:tool_call_start, index:, id: tool.id, name: tool.name),
|
|
179
205
|
StreamEvent.build(:tool_call_stop, index:, tool_use: tool)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "openai_compatible"
|
|
4
|
+
require_relative "decision_http"
|
|
4
5
|
require_relative "open_router/catalog_source"
|
|
5
6
|
|
|
6
7
|
module LittleGhost
|
|
@@ -20,6 +21,8 @@ module LittleGhost
|
|
|
20
21
|
# Requests that need a capability ask OpenRouter to route only to providers
|
|
21
22
|
# that advertise it.
|
|
22
23
|
class OpenRouter < OpenAICompatible
|
|
24
|
+
include DecisionHTTP
|
|
25
|
+
|
|
23
26
|
# Adds OpenRouter attribution to the shared OpenAI-compatible policy.
|
|
24
27
|
def self.request_options = (super + [:app_name]).freeze
|
|
25
28
|
|
|
@@ -38,12 +41,26 @@ module LittleGhost
|
|
|
38
41
|
|
|
39
42
|
# Configures an OpenRouter client. All OpenAICompatible options, including
|
|
40
43
|
# +api_key+, +model+, retry limits, timeouts, and custom transport, apply.
|
|
41
|
-
def initialize(site_url: nil, app_name: nil, base_url: DEFAULT_BASE_URL, **arguments)
|
|
44
|
+
def initialize(site_url: nil, app_name: nil, base_url: DEFAULT_BASE_URL, decision_api: :decisions, **arguments)
|
|
42
45
|
@site_url = site_url
|
|
43
46
|
@app_name = app_name
|
|
47
|
+
@decision_api = decision_api.to_sym
|
|
48
|
+
raise ConfigurationError, "decision_api must be :decisions or :system_one" unless %i[decisions system_one].include?(@decision_api)
|
|
49
|
+
@decision_base_url = base_url
|
|
44
50
|
super(base_url:, api: :chat_completions, **arguments)
|
|
45
51
|
end
|
|
46
52
|
|
|
53
|
+
# Executes typed decisions through OpenRouter's selected Jev endpoint.
|
|
54
|
+
def decide(request)
|
|
55
|
+
base_url = @decision_base_url.end_with?("/") ? @decision_base_url : "#{@decision_base_url}/"
|
|
56
|
+
endpoint = if @decision_api == :decisions
|
|
57
|
+
URI.join(base_url, "../alpha/decisions").to_s
|
|
58
|
+
else
|
|
59
|
+
URI.join(base_url, "systemone").to_s
|
|
60
|
+
end
|
|
61
|
+
send_decision(request, endpoint:, model:, api_key: @api_key)
|
|
62
|
+
end
|
|
63
|
+
|
|
47
64
|
# Reads model capabilities from OpenRouter +supported_parameters+ metadata.
|
|
48
65
|
# Missing metadata produces ModelCapabilities.unknown.
|
|
49
66
|
def capabilities(metadata: {})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "decision_http"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
module Providers
|
|
7
|
+
# Direct TypeSafe Jev decision API connection.
|
|
8
|
+
class Typesafe < Base
|
|
9
|
+
include DecisionHTTP
|
|
10
|
+
|
|
11
|
+
# The TypeSafe API base URL.
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.typesafe.ai/v1/"
|
|
13
|
+
|
|
14
|
+
# Configures a direct Jev connection. Pass +transport+ to supply a custom
|
|
15
|
+
# bounded HTTP client.
|
|
16
|
+
def initialize(api_key:, model:, base_url: DEFAULT_BASE_URL, transport: nil,
|
|
17
|
+
open_timeout: 10, read_timeout: 120, max_response_bytes: Support::HTTPClient::DEFAULT_MAX_RESPONSE_BYTES)
|
|
18
|
+
@model = model
|
|
19
|
+
@decision_api_key = api_key
|
|
20
|
+
@decision_base_url = base_url
|
|
21
|
+
@decision_transport = transport || Support::HTTPClient.new(
|
|
22
|
+
base_url:, open_timeout:, read_timeout:, max_response_bytes:
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def decide(request)
|
|
27
|
+
send_decision(request, endpoint: "systemone", model: @model, api_key: @decision_api_key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stream(_request)
|
|
31
|
+
raise UnsupportedModelOperationError, "TypeSafe supports decisions only"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/little_ghost/runtime.rb
CHANGED
|
@@ -208,6 +208,11 @@ module LittleGhost
|
|
|
208
208
|
@model_operations.embed(**arguments)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# Runs typed decision questions without creating a Run.
|
|
212
|
+
def decide(**arguments)
|
|
213
|
+
@model_operations.decide(**arguments)
|
|
214
|
+
end
|
|
215
|
+
|
|
211
216
|
# Creates a Run that owns any workspace and sandbox built for the request.
|
|
212
217
|
#
|
|
213
218
|
# +include_agent_events_by_default+ defaults to +true+, exposing source-tagged
|
data/lib/little_ghost/version.rb
CHANGED
data/lib/little_ghost.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "little_ghost/model_response"
|
|
|
23
23
|
require_relative "little_ghost/embeddings/request"
|
|
24
24
|
require_relative "little_ghost/embeddings/response"
|
|
25
25
|
require_relative "little_ghost/run_result"
|
|
26
|
+
require_relative "little_ghost/decision"
|
|
26
27
|
require_relative "little_ghost/model_capabilities"
|
|
27
28
|
require_relative "little_ghost/models/target"
|
|
28
29
|
require_relative "little_ghost/models/details"
|
|
@@ -37,6 +38,7 @@ require_relative "little_ghost/providers/openai_compatible"
|
|
|
37
38
|
require_relative "little_ghost/providers/openai"
|
|
38
39
|
require_relative "little_ghost/providers/lm_studio"
|
|
39
40
|
require_relative "little_ghost/providers/open_router"
|
|
41
|
+
require_relative "little_ghost/providers/typesafe"
|
|
40
42
|
require_relative "little_ghost/providers/bedrock"
|
|
41
43
|
require_relative "little_ghost/providers/anthropic"
|
|
42
44
|
require_relative "little_ghost/providers/gemini"
|
|
@@ -180,6 +182,9 @@ module LittleGhost
|
|
|
180
182
|
# controls. The operation raises rather than returning a partial batch.
|
|
181
183
|
def embed(...) = runtime.embed(...)
|
|
182
184
|
|
|
185
|
+
# Runs typed choice, noul, or score questions against a decision model.
|
|
186
|
+
def decide(...) = runtime.decide(...)
|
|
187
|
+
|
|
183
188
|
# Makes +configuration+ and its independent shared Runtime current only
|
|
184
189
|
# while the block runs.
|
|
185
190
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: little_ghost
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Robinson
|
|
@@ -191,6 +191,8 @@ files:
|
|
|
191
191
|
- docs/guides/assemblies.md
|
|
192
192
|
- docs/guides/code_mode.md
|
|
193
193
|
- docs/guides/core_concepts.md
|
|
194
|
+
- docs/guides/decisions.md
|
|
195
|
+
- docs/guides/embeddings.md
|
|
194
196
|
- docs/guides/getting_started.md
|
|
195
197
|
- docs/guides/integrations.md
|
|
196
198
|
- docs/guides/mcp.md
|
|
@@ -245,6 +247,7 @@ files:
|
|
|
245
247
|
- lib/little_ghost/content.rb
|
|
246
248
|
- lib/little_ghost/data/model_catalog.json
|
|
247
249
|
- lib/little_ghost/data_map.rb
|
|
250
|
+
- lib/little_ghost/decision.rb
|
|
248
251
|
- lib/little_ghost/embeddings/request.rb
|
|
249
252
|
- lib/little_ghost/embeddings/response.rb
|
|
250
253
|
- lib/little_ghost/errors.rb
|
|
@@ -484,6 +487,7 @@ files:
|
|
|
484
487
|
- lib/little_ghost/providers/bedrock/credential_resolver.rb
|
|
485
488
|
- lib/little_ghost/providers/bedrock/http_client.rb
|
|
486
489
|
- lib/little_ghost/providers/configuration.rb
|
|
490
|
+
- lib/little_ghost/providers/decision_http.rb
|
|
487
491
|
- lib/little_ghost/providers/gemini.rb
|
|
488
492
|
- lib/little_ghost/providers/gemini/catalog_source.rb
|
|
489
493
|
- lib/little_ghost/providers/lm_studio.rb
|
|
@@ -492,6 +496,7 @@ files:
|
|
|
492
496
|
- lib/little_ghost/providers/open_router/catalog_source.rb
|
|
493
497
|
- lib/little_ghost/providers/openai.rb
|
|
494
498
|
- lib/little_ghost/providers/openai_compatible.rb
|
|
499
|
+
- lib/little_ghost/providers/typesafe.rb
|
|
495
500
|
- lib/little_ghost/providers/vertex_ai.rb
|
|
496
501
|
- lib/little_ghost/providers/vertex_ai/credential_resolver.rb
|
|
497
502
|
- lib/little_ghost/run.rb
|