agent-harness 0.39.0 → 0.41.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +65 -0
- data/docs/provider-neutral-api-execution-contract.md +71 -7
- data/lib/agent_harness/api/chat_transport.rb +415 -0
- data/lib/agent_harness/api/ruby_llm_chat_adapter.rb +273 -0
- data/lib/agent_harness/embedding_adapter.rb +39 -0
- data/lib/agent_harness/embedding_result.rb +20 -0
- data/lib/agent_harness/embeddings.rb +257 -0
- data/lib/agent_harness/errors.rb +22 -2
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +10 -0
- metadata +40 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4406422d8b90ec6406bb4cdcc6e1d7496b708f43aad306d258c2570ed4f710e
|
|
4
|
+
data.tar.gz: cd23fe2f7d4b05e2ac18ac9aa2a631a0b292bc2703ae092f4f55a1ca6f3febbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efb5e61347d70b28e03bb0cd2843429928843147bf8f196d1292af97be97c8ec67ca4ef81e6f169562d1fac5e88e277b06d07a7cd44bf3d85a772effb2c46051
|
|
7
|
+
data.tar.gz: 7c5fb37c7e14edbc3da68f74dc3f1a6d10e04630e70509a79bd04b9ebbfde2397d6a4753e288dd33d962efae79003728d30cdb0ae113d1bd4864de5995daa112
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
* add runner model compatibility contract (`AgentHarness.model_compatibility`) with structured `ModelCompatibility::Result` outcomes. Codex exposes static facts for CLI-gated models (e.g. `gpt-5.5` requires Codex CLI `>= 0.116.0`), a baseline supported-model list, supported auth modes, and a `DEFAULT_COMPATIBLE_MODEL_ID` fallback so downstream orchestrators can validate tier/model assignments before scheduling agent runs ([#259](https://github.com/viamin/agent-harness/issues/259)).
|
|
6
6
|
* **auth:** add provider-owned PKCE code-exchange API for Claude OAuth (`AgentHarness::Authentication.exchange_code`). Takes an authorization code plus PKCE verifier (and `redirect_uri`/`client_id`), posts an `authorization_code` grant to the Claude token endpoint, and persists the resulting access/refresh tokens in the native `claudeAiOauth` shape. Adds `exchange_code_supported?` and a `code_exchange` key to `auth_capabilities` ([#266](https://github.com/viamin/agent-harness/issues/266)).
|
|
7
7
|
|
|
8
|
+
## [0.41.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.40.0...agent-harness/v0.41.0) (2026-09-25)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Normalize API Chat Transport, Tools and Streaming (RDR-072) ([#441](https://github.com/viamin/agent-harness/issues/441)) ([761c118](https://github.com/viamin/agent-harness/commit/761c11842ccacd18d91e9412f71cb98d76afa3b2))
|
|
14
|
+
|
|
15
|
+
## [0.40.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.39.0...agent-harness/v0.40.0) (2026-09-25)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* Provide Native Embedding Support (RDR-072) ([#439](https://github.com/viamin/agent-harness/issues/439)) ([551378d](https://github.com/viamin/agent-harness/commit/551378d11627a5227059032f0b7418dc166f54e9))
|
|
21
|
+
|
|
8
22
|
## [0.39.0](https://github.com/viamin/agent-harness/compare/agent-harness/v0.38.0...agent-harness/v0.39.0) (2026-09-25)
|
|
9
23
|
|
|
10
24
|
|
data/README.md
CHANGED
|
@@ -39,6 +39,71 @@ puts response.output
|
|
|
39
39
|
response = AgentHarness.send_message("Explain this code", provider: :cursor)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Native Embeddings
|
|
43
|
+
|
|
44
|
+
`AgentHarness.embed` sends a whole input batch through RubyLLM and returns one
|
|
45
|
+
vector per input, in the same order. Credentials, endpoint, headers, timeout,
|
|
46
|
+
and retry limits are request-local; they do not change `RubyLLM.config` or the
|
|
47
|
+
CLI/subscription provider configuration.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
result = AgentHarness.embed(
|
|
51
|
+
inputs: ["first document", "second document"],
|
|
52
|
+
model: "text-embedding-3-small",
|
|
53
|
+
dimensions: 512,
|
|
54
|
+
endpoint: "https://api.openai.com/v1", # optional OpenAI-compatible base URL
|
|
55
|
+
credentials: {api_key: ENV.fetch("EMBEDDING_API_KEY")},
|
|
56
|
+
headers: {"X-Tenant-ID" => tenant.external_id},
|
|
57
|
+
timeout: 30,
|
|
58
|
+
max_attempts: 3,
|
|
59
|
+
cancellation: -> { request_cancelled? }
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result.vectors # one vector for each input
|
|
63
|
+
result.usage # { input_tokens: 42 }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`credentials` may also be the API key string. Extra headers cannot replace the
|
|
67
|
+
`Authorization` header; change credentials explicitly instead. `max_attempts`
|
|
68
|
+
includes the initial request. Agent Harness performs the only retry loop: 429,
|
|
69
|
+
timeout/connection, and transient 5xx failures are retried up to that bound,
|
|
70
|
+
while 401 and 403 responses fail immediately. The harness honors `Retry-After`
|
|
71
|
+
within its bounded retry policy. A cancellation callable is checked immediately
|
|
72
|
+
before every physical HTTP attempt.
|
|
73
|
+
|
|
74
|
+
Usage is the provider-reported total for the complete batch. When the provider
|
|
75
|
+
omits usage, `result.usage[:input_tokens]` remains `nil`. The harness does not
|
|
76
|
+
estimate usage or allocate a batch total across vectors, so
|
|
77
|
+
`result.per_vector_usage` is always `nil`.
|
|
78
|
+
|
|
79
|
+
Authentication failures raise `AgentHarness::AuthenticationError`, exhausted
|
|
80
|
+
rate limits raise `AgentHarness::RateLimitError`, timeouts raise
|
|
81
|
+
`AgentHarness::TimeoutError`, transient provider failures raise
|
|
82
|
+
`AgentHarness::ProviderError`, cancellations raise
|
|
83
|
+
`AgentHarness::CancelledError`, and incomplete or invalid vector batches raise
|
|
84
|
+
`AgentHarness::MalformedEmbeddingError`. Empty input returns an empty result
|
|
85
|
+
without contacting the provider.
|
|
86
|
+
|
|
87
|
+
### Migrating from Paid transport patches
|
|
88
|
+
|
|
89
|
+
This operation replaces downstream host/container embedding transport
|
|
90
|
+
extensions for OpenAI-compatible direct and proxy endpoints. After adopting an
|
|
91
|
+
agent-harness release containing this capability:
|
|
92
|
+
|
|
93
|
+
1. Run the downstream embedding contract suite against both the direct provider
|
|
94
|
+
and proxy endpoint, including tenant-specific credentials and headers.
|
|
95
|
+
2. Verify the released gem artifact includes `AgentHarness.embed` and record the
|
|
96
|
+
passing artifact version or digest. Issue closure or a Git tag alone is not
|
|
97
|
+
release evidence.
|
|
98
|
+
3. Switch only the embedding call site to `AgentHarness.embed`; leave unrelated
|
|
99
|
+
chat, schema, CLI, and subscription paths unchanged.
|
|
100
|
+
4. Remove the downstream embedding request/parser/retry patch so Agent Harness
|
|
101
|
+
owns the single bounded retry loop. Keep durable workflow recovery and
|
|
102
|
+
accounting in the downstream application.
|
|
103
|
+
|
|
104
|
+
The runtime dependency is Ruby 3.2 or newer and RubyLLM 2.x. No Rails database
|
|
105
|
+
or RubyLLM persistence tables are required for this plain-Ruby operation.
|
|
106
|
+
|
|
42
107
|
## Configuration
|
|
43
108
|
|
|
44
109
|
### Ruby DSL
|
|
@@ -7,19 +7,83 @@ usage, and optional conversation-persistence work.
|
|
|
7
7
|
|
|
8
8
|
## Status and rollout boundary
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
contract tests, implementation, release evidence, and
|
|
14
|
-
evidence before a caller enables it.
|
|
10
|
+
RDR-072's rollout guard was **docs-only** for the design phase. The normalized
|
|
11
|
+
chat capability described in "Shipped normalized chat surface" below is now
|
|
12
|
+
implemented. Other capabilities remain design contracts and each still needs
|
|
13
|
+
its own failing-first contract tests, implementation, release evidence, and
|
|
14
|
+
downstream adoption evidence before a caller enables it.
|
|
15
15
|
|
|
16
16
|
Existing CLI and subscription behavior remains the default. Existing
|
|
17
17
|
`TextTransport`, `OpenAICompatibleTransport`, `Conversation`, and `Response`
|
|
18
|
-
interfaces remain available
|
|
19
|
-
caller must not infer support
|
|
18
|
+
interfaces remain available and unchanged; they are not aliases for the
|
|
19
|
+
normalized API. A caller must not infer support for another capability from a
|
|
20
|
+
gem version or issue closure.
|
|
20
21
|
|
|
21
22
|
Normative words such as MUST and MUST NOT describe the future public boundary.
|
|
22
23
|
|
|
24
|
+
## Shipped normalized chat surface
|
|
25
|
+
|
|
26
|
+
`AgentHarness::Api::ChatTransport#call` implements one normalized assistant
|
|
27
|
+
response while leaving the conversation loop and all application tool
|
|
28
|
+
execution with the caller:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
transport = AgentHarness::Api::ChatTransport.new
|
|
32
|
+
result = transport.call(request.merge(
|
|
33
|
+
operation: :chat,
|
|
34
|
+
messages: [
|
|
35
|
+
{id: "system-1", role: :system,
|
|
36
|
+
content: [{type: :text, text: "Be concise"}]},
|
|
37
|
+
{id: "user-1", role: :user,
|
|
38
|
+
content: [{type: :text, text: "Summarize this"}]}
|
|
39
|
+
],
|
|
40
|
+
tools: [{
|
|
41
|
+
name: "lookup",
|
|
42
|
+
description: "Looks up a record",
|
|
43
|
+
input_schema: {type: "object", properties: {id: {type: "string"}}}
|
|
44
|
+
}],
|
|
45
|
+
max_output_tokens: 1_000,
|
|
46
|
+
stream: true
|
|
47
|
+
), observer: ->(event) { events << event })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The returned value is the normalized result hash in this document. The
|
|
51
|
+
observer is either callable or responds to `on_chat_event`. It receives ordered
|
|
52
|
+
events with request/attempt identity and sequence numbers. This transport never
|
|
53
|
+
invokes a supplied tool; callers append completed tool results to a later
|
|
54
|
+
request. A failed partial stream is terminal, so its content cannot be appended
|
|
55
|
+
to a fallback response and its tool calls cannot be replayed automatically.
|
|
56
|
+
|
|
57
|
+
Streamed tool-call arguments are emitted as raw, appendable JSON fragments
|
|
58
|
+
correlated to one `tool_call_started` event per provider call, and cumulative
|
|
59
|
+
provider token counts are emitted as deduplicated `usage_updated` events. An
|
|
60
|
+
observer that raises aborts the in-flight request and surfaces as
|
|
61
|
+
`AgentHarness::Api::ChatTransport::ObserverError` with the original failure as
|
|
62
|
+
its `cause`; it is never classified as a provider error, never retried, and the
|
|
63
|
+
failed observer is not invoked again.
|
|
64
|
+
|
|
65
|
+
The verified scopes are Anthropic with `protocol: :messages`, OpenAI with
|
|
66
|
+
`protocol: :responses` or `:chat_completions`, and OpenAI-compatible endpoints
|
|
67
|
+
with `provider: :openai`, an explicit `endpoint`, and
|
|
68
|
+
`protocol: :chat_completions`. Compatible endpoints that do not implement the
|
|
69
|
+
Responses API must select `:chat_completions`; the transport never probes and
|
|
70
|
+
silently switches protocols. Only `authentication_mode: :api_key` is currently
|
|
71
|
+
supported.
|
|
72
|
+
|
|
73
|
+
Credentials, endpoint, custom headers, timeout, and RubyLLM configuration are
|
|
74
|
+
isolated with a request-local `RubyLLM::Context`. RubyLLM middleware retries
|
|
75
|
+
are disabled; `retry.max_attempts` is the total physical-attempt limit owned by
|
|
76
|
+
the harness. Authentication headers cannot be overridden by custom headers.
|
|
77
|
+
`max_output_tokens` is forwarded without changing it.
|
|
78
|
+
|
|
79
|
+
Unknown model IDs are allowed only because a complete provider and protocol
|
|
80
|
+
are explicit in every candidate (`assume_model_exists: true` in the RubyLLM
|
|
81
|
+
adapter). This skips registry validation; it does not assert that the endpoint
|
|
82
|
+
supports the model. Provider rejection returns a classified failure. Custom
|
|
83
|
+
endpoints retain the selected provider's wire protocol and authentication
|
|
84
|
+
shape. Custom provider types, authentication modes, media content, and
|
|
85
|
+
automatic protocol discovery are not supported by this capability.
|
|
86
|
+
|
|
23
87
|
## Ownership boundary
|
|
24
88
|
|
|
25
89
|
AgentHarness owns protocol translation and one bounded provider request:
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
require_relative "ruby_llm_chat_adapter"
|
|
6
|
+
|
|
7
|
+
module AgentHarness
|
|
8
|
+
module Api
|
|
9
|
+
# Executes one normalized chat response without running application tools.
|
|
10
|
+
class ChatTransport
|
|
11
|
+
RESERVED_HEADERS = {
|
|
12
|
+
anthropic: %w[x-api-key anthropic-version],
|
|
13
|
+
openai: %w[authorization]
|
|
14
|
+
}.freeze
|
|
15
|
+
PROTOCOLS = {
|
|
16
|
+
anthropic: %i[messages],
|
|
17
|
+
openai: %i[responses chat_completions]
|
|
18
|
+
}.freeze
|
|
19
|
+
DEFAULT_RETRY = {max_attempts: 1, base_delay_seconds: 0, max_delay_seconds: 0}.freeze
|
|
20
|
+
|
|
21
|
+
# Raised when the caller's observer fails. Observer bugs stay outside
|
|
22
|
+
# provider error classification, abort the in-flight request, and are
|
|
23
|
+
# never re-invoked with a synthetic terminal event.
|
|
24
|
+
class ObserverError < StandardError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(adapter: RubyLlmChatAdapter.new, id_generator: -> { SecureRandom.uuid }, sleeper: Kernel.method(:sleep))
|
|
28
|
+
@adapter = adapter
|
|
29
|
+
@id_generator = id_generator
|
|
30
|
+
@sleeper = sleeper
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(request, observer: nil, &on_event)
|
|
34
|
+
execution = Execution.new(request, adapter: @adapter, id_generator: @id_generator,
|
|
35
|
+
sleeper: @sleeper, observer: observer || on_event)
|
|
36
|
+
execution.call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Holds mutable state for exactly one request, keeping the transport reusable.
|
|
40
|
+
class Execution
|
|
41
|
+
def initialize(request, adapter:, id_generator:, sleeper:, observer:)
|
|
42
|
+
@request = symbolize(request)
|
|
43
|
+
@adapter = adapter
|
|
44
|
+
@id_generator = id_generator
|
|
45
|
+
@sleeper = sleeper
|
|
46
|
+
@observer = observer
|
|
47
|
+
@attempts = []
|
|
48
|
+
@sequence = 0
|
|
49
|
+
@partial_content = +""
|
|
50
|
+
@stream_tool_calls = {}
|
|
51
|
+
@tool_ids = {}
|
|
52
|
+
validate!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call
|
|
56
|
+
return cancelled_result unless active?
|
|
57
|
+
|
|
58
|
+
candidates.each_with_index do |candidate, candidate_index|
|
|
59
|
+
result = attempt_candidate(candidate, candidate_index)
|
|
60
|
+
return result if terminal?(result)
|
|
61
|
+
end
|
|
62
|
+
failed_result(@last_error, @last_candidate)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :request, :attempts
|
|
68
|
+
|
|
69
|
+
def attempt_candidate(candidate, candidate_index)
|
|
70
|
+
loop do
|
|
71
|
+
return cancelled_result(candidate) unless active?
|
|
72
|
+
return failed_result(@last_error, candidate) if attempts.length >= retry_config[:max_attempts]
|
|
73
|
+
|
|
74
|
+
result = perform_attempt(candidate)
|
|
75
|
+
return result if result[:status] == :succeeded || result[:status] == :partial
|
|
76
|
+
return cancelled_result(candidate) unless active?
|
|
77
|
+
|
|
78
|
+
@last_error = result[:error]
|
|
79
|
+
@last_candidate = candidate
|
|
80
|
+
return result if select_fallback(result, candidate_index)
|
|
81
|
+
return result unless retryable?(result)
|
|
82
|
+
|
|
83
|
+
backoff
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def perform_attempt(candidate)
|
|
88
|
+
attempt_id = @id_generator.call
|
|
89
|
+
started_at = Time.now.utc
|
|
90
|
+
emitted = false
|
|
91
|
+
streamed_usage = nil
|
|
92
|
+
emit(:response_started, attempt_id:, candidate: candidate_identity(candidate))
|
|
93
|
+
|
|
94
|
+
adapter_result = @adapter.call(
|
|
95
|
+
candidate: candidate,
|
|
96
|
+
messages: request[:messages],
|
|
97
|
+
tools: request[:tools] || [],
|
|
98
|
+
max_output_tokens: request[:max_output_tokens],
|
|
99
|
+
temperature: request[:temperature],
|
|
100
|
+
stream: request[:stream] == true,
|
|
101
|
+
timeout: request[:timeout],
|
|
102
|
+
cancellation: request[:cancellation]
|
|
103
|
+
) do |event|
|
|
104
|
+
event = normalize_stream_event(event)
|
|
105
|
+
emitted = true if output_event?(event)
|
|
106
|
+
streamed_usage = event.except(:type) if event[:type] == :usage_updated
|
|
107
|
+
accumulate(event)
|
|
108
|
+
emit(event.fetch(:type), attempt_id:, **event.except(:type))
|
|
109
|
+
end
|
|
110
|
+
raise RubyLLM::CancelledError unless active?
|
|
111
|
+
|
|
112
|
+
success(candidate, attempt_id, started_at, adapter_result)
|
|
113
|
+
rescue ObserverError
|
|
114
|
+
raise
|
|
115
|
+
rescue => error
|
|
116
|
+
failure(candidate, attempt_id, started_at, classify(error), partial: emitted, usage: streamed_usage)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def success(candidate, attempt_id, started_at, adapter_result)
|
|
120
|
+
attempts << attempt_report(candidate, attempt_id, started_at, :succeeded, usage: adapter_result[:usage])
|
|
121
|
+
result = base_result(candidate).merge(
|
|
122
|
+
status: :succeeded,
|
|
123
|
+
content: adapter_result[:content] || "",
|
|
124
|
+
tool_calls: normalize_tool_calls(adapter_result[:tool_calls]),
|
|
125
|
+
finish_reason: adapter_result[:finish_reason],
|
|
126
|
+
usage: aggregate_usage,
|
|
127
|
+
error: nil
|
|
128
|
+
)
|
|
129
|
+
emit(:response_completed, attempt_id:, result: result)
|
|
130
|
+
result
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def failure(candidate, attempt_id, started_at, error, partial:, usage:)
|
|
134
|
+
status = failure_status(error, partial)
|
|
135
|
+
error = error.merge(retryable: false) if partial
|
|
136
|
+
attempts << attempt_report(candidate, attempt_id, started_at, status, error: error, usage: usage)
|
|
137
|
+
result = base_result(candidate).merge(
|
|
138
|
+
status: status,
|
|
139
|
+
content: partial ? @partial_content.dup : "",
|
|
140
|
+
tool_calls: partial_tool_calls,
|
|
141
|
+
finish_reason: nil,
|
|
142
|
+
usage: aggregate_usage,
|
|
143
|
+
error: error
|
|
144
|
+
)
|
|
145
|
+
emit((status == :cancelled) ? :response_cancelled : :response_failed, attempt_id:, result: result)
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fallback_allowed?(result, candidate_index)
|
|
150
|
+
return false if result[:status] == :partial
|
|
151
|
+
return false unless fallback_categories.include?(result.dig(:error, :category))
|
|
152
|
+
return false unless candidates[candidate_index + 1]
|
|
153
|
+
|
|
154
|
+
true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def select_fallback(result, candidate_index)
|
|
158
|
+
return false unless fallback_allowed?(result, candidate_index)
|
|
159
|
+
|
|
160
|
+
next_candidate = candidates[candidate_index + 1]
|
|
161
|
+
emit(:fallback_selected, attempt_id: attempts.last[:attempt_id],
|
|
162
|
+
from: candidate_identity(candidates[candidate_index]),
|
|
163
|
+
to: candidate_identity(next_candidate), error: result[:error])
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def retryable?(result)
|
|
168
|
+
result.dig(:error, :retryable) && attempts.length < retry_config[:max_attempts]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def terminal?(result)
|
|
172
|
+
return true if %i[succeeded partial cancelled].include?(result[:status])
|
|
173
|
+
return true unless fallback_categories.include?(result.dig(:error, :category))
|
|
174
|
+
|
|
175
|
+
attempts.length >= retry_config[:max_attempts]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def backoff
|
|
179
|
+
exponent = [attempts.length - 1, 0].max
|
|
180
|
+
delay = retry_config[:base_delay_seconds] * (2**exponent)
|
|
181
|
+
cap = retry_config[:max_delay_seconds]
|
|
182
|
+
remaining = cap&.positive? ? [delay, cap].min : delay
|
|
183
|
+
while remaining.positive? && active?
|
|
184
|
+
interval = [remaining, 0.05].min
|
|
185
|
+
@sleeper.call(interval)
|
|
186
|
+
remaining -= interval
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def emit(type, attempt_id:, **payload)
|
|
191
|
+
@sequence += 1
|
|
192
|
+
event = payload.merge(type: type, request_id: request[:request_id], attempt_id: attempt_id, sequence: @sequence)
|
|
193
|
+
deliver(event) if @observer
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Observer exceptions are caller bugs, not provider failures: they
|
|
197
|
+
# abort the in-flight request and surface directly instead of being
|
|
198
|
+
# classified or retried as provider errors.
|
|
199
|
+
def deliver(event)
|
|
200
|
+
@observer.respond_to?(:on_chat_event) ? @observer.on_chat_event(event) : @observer.call(event)
|
|
201
|
+
rescue => error
|
|
202
|
+
raise ObserverError, "chat observer failed: #{error.class} #{error.message}", error.backtrace
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def accumulate(event)
|
|
206
|
+
@partial_content << event[:content].to_s if event[:type] == :text_delta
|
|
207
|
+
return unless event[:id]
|
|
208
|
+
|
|
209
|
+
call = (@stream_tool_calls[event[:id]] ||= {
|
|
210
|
+
id: event[:id], provider_id: event[:provider_id], name: event[:name],
|
|
211
|
+
arguments_json: +"", status: :incomplete
|
|
212
|
+
})
|
|
213
|
+
call[:arguments_json] << event[:arguments_json].to_s if event[:type] == :tool_call_delta
|
|
214
|
+
if event[:type] == :tool_call_completed
|
|
215
|
+
call[:arguments_json] = event[:arguments_json]
|
|
216
|
+
call[:status] = :completed
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def output_event?(event)
|
|
221
|
+
%i[text_delta tool_call_started tool_call_delta tool_call_completed].include?(event[:type])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def partial_tool_calls
|
|
225
|
+
@stream_tool_calls.values.map(&:dup)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def normalize_tool_calls(tool_calls)
|
|
229
|
+
Array(tool_calls).map do |call|
|
|
230
|
+
call.merge(id: tool_id(call[:provider_id]), status: :completed)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def normalize_stream_event(event)
|
|
235
|
+
return event unless event[:provider_id]
|
|
236
|
+
|
|
237
|
+
event.merge(id: tool_id(event[:provider_id]))
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def tool_id(provider_id)
|
|
241
|
+
@tool_ids[provider_id] ||= @id_generator.call
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def base_result(candidate)
|
|
245
|
+
{
|
|
246
|
+
request_id: request[:request_id],
|
|
247
|
+
provider: candidate&.dig(:provider),
|
|
248
|
+
model: candidate&.dig(:model),
|
|
249
|
+
protocol: candidate&.dig(:protocol),
|
|
250
|
+
authentication_mode: candidate&.dig(:authentication_mode),
|
|
251
|
+
parsed: nil,
|
|
252
|
+
attempts: attempts.dup,
|
|
253
|
+
provider_request_id: nil
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def failed_result(error, candidate)
|
|
258
|
+
base_result(candidate).merge(status: :failed, content: "", tool_calls: [], finish_reason: nil,
|
|
259
|
+
usage: aggregate_usage, error: error)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def cancelled_result(candidate = candidates.first)
|
|
263
|
+
error = {category: :cancelled, code: :cancelled, retryable: false, message: "Request cancelled"}
|
|
264
|
+
base_result(candidate).merge(status: :cancelled, content: "", tool_calls: [], finish_reason: nil,
|
|
265
|
+
usage: aggregate_usage, error: error)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def attempt_report(candidate, attempt_id, started_at, status, error: nil, usage: nil)
|
|
269
|
+
{
|
|
270
|
+
attempt_id: attempt_id,
|
|
271
|
+
request_id: request[:request_id],
|
|
272
|
+
number: attempts.length + 1,
|
|
273
|
+
provider: candidate[:provider],
|
|
274
|
+
model: candidate[:model],
|
|
275
|
+
status: status,
|
|
276
|
+
started_at: started_at.iso8601(6),
|
|
277
|
+
finished_at: Time.now.utc.iso8601(6),
|
|
278
|
+
usage: usage,
|
|
279
|
+
cost: nil,
|
|
280
|
+
provider_reported: false,
|
|
281
|
+
error: error
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def aggregate_usage
|
|
286
|
+
reports = attempts.filter_map { |attempt| attempt[:usage] }
|
|
287
|
+
return if reports.empty?
|
|
288
|
+
|
|
289
|
+
%i[input_tokens output_tokens total_tokens].to_h do |key|
|
|
290
|
+
values = reports.filter_map { |usage| usage[key] }
|
|
291
|
+
[key, values.empty? ? nil : values.sum]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def classify(error)
|
|
296
|
+
ErrorClassifier.call(error)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def failure_status(error, partial)
|
|
300
|
+
return :cancelled if error[:category] == :cancelled
|
|
301
|
+
return :partial if partial
|
|
302
|
+
|
|
303
|
+
:failed
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def candidates = request[:candidates]
|
|
307
|
+
def retry_config = @retry_config ||= DEFAULT_RETRY.merge(request[:retry] || {})
|
|
308
|
+
def fallback_categories = Array(request.dig(:fallback, :on_error_categories)).map(&:to_sym)
|
|
309
|
+
|
|
310
|
+
def active?
|
|
311
|
+
token = request[:cancellation]
|
|
312
|
+
return true unless token
|
|
313
|
+
|
|
314
|
+
cancelled = token.respond_to?(:cancelled?) ? token.cancelled? : token.call
|
|
315
|
+
!cancelled
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def validate!
|
|
319
|
+
raise ArgumentError, "operation must be :chat" unless request[:operation]&.to_sym == :chat
|
|
320
|
+
raise ArgumentError, "request_id is required" if request[:request_id].to_s.empty?
|
|
321
|
+
raise ArgumentError, "candidates must not be empty" if !request[:candidates].is_a?(Array) || request[:candidates].empty?
|
|
322
|
+
raise ArgumentError, "messages must be an array" unless request[:messages].is_a?(Array)
|
|
323
|
+
validate_attempt_limit!
|
|
324
|
+
candidates.each { |candidate| validate_candidate!(candidate) }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def validate_attempt_limit!
|
|
328
|
+
limit = retry_config[:max_attempts]
|
|
329
|
+
raise ArgumentError, "retry.max_attempts must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def validate_candidate!(candidate)
|
|
333
|
+
candidate.replace(symbolize(candidate))
|
|
334
|
+
%i[provider model protocol authentication_mode credentials].each do |key|
|
|
335
|
+
raise ArgumentError, "candidate.#{key} is required" if candidate[key].nil?
|
|
336
|
+
end
|
|
337
|
+
raise ArgumentError, "only api_key authentication is supported" unless candidate[:authentication_mode].to_sym == :api_key
|
|
338
|
+
supported_protocols = PROTOCOLS[candidate[:provider].to_sym]
|
|
339
|
+
unless supported_protocols&.include?(candidate[:protocol].to_sym)
|
|
340
|
+
raise ArgumentError, "unsupported provider/protocol combination"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
headers = candidate[:headers] || {}
|
|
344
|
+
reserved_names = RESERVED_HEADERS.fetch(candidate[:provider].to_sym, RESERVED_HEADERS.values.flatten)
|
|
345
|
+
reserved = headers.keys.map { |key| key.to_s.downcase } & reserved_names
|
|
346
|
+
raise ArgumentError, "reserved header override: #{reserved.first}" if reserved.any?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def candidate_identity(candidate)
|
|
350
|
+
candidate.slice(:provider, :model, :protocol, :authentication_mode)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def symbolize(value)
|
|
354
|
+
if value.is_a?(Hash)
|
|
355
|
+
return value.each_with_object({}) do |(key, child), normalized|
|
|
356
|
+
symbol = key.to_sym
|
|
357
|
+
normalized[symbol] = (symbol == :headers) ? child.dup : symbolize(child)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
return value.map { |child| symbolize(child) } if value.is_a?(Array)
|
|
361
|
+
|
|
362
|
+
value
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
module ErrorClassifier
|
|
368
|
+
TRANSIENT = {
|
|
369
|
+
Faraday::TimeoutError => :timeout,
|
|
370
|
+
Faraday::ConnectionFailed => :connection_failed,
|
|
371
|
+
Faraday::SSLError => :connection_failed,
|
|
372
|
+
RubyLLM::RateLimitError => :rate_limited,
|
|
373
|
+
RubyLLM::ServerError => :server_error,
|
|
374
|
+
RubyLLM::ServiceUnavailableError => :service_unavailable,
|
|
375
|
+
RubyLLM::OverloadedError => :overloaded
|
|
376
|
+
}.freeze
|
|
377
|
+
|
|
378
|
+
NON_RETRYABLE = {
|
|
379
|
+
RubyLlmChatAdapter::MissingCredentialError => [:authentication, :invalid_credential],
|
|
380
|
+
RubyLLM::UnauthorizedError => [:authentication, :invalid_credential],
|
|
381
|
+
RubyLLM::ForbiddenError => [:authorization, :permission_denied],
|
|
382
|
+
RubyLLM::PaymentRequiredError => [:billing, :billing_unavailable],
|
|
383
|
+
RubyLLM::ContextLengthExceededError => [:context_length, :context_length_exceeded],
|
|
384
|
+
RubyLLM::BadRequestError => [:invalid_request, :invalid_request],
|
|
385
|
+
RubyLlmChatAdapter::UnsupportedOptionError => [:unsupported, :unsupported_capability],
|
|
386
|
+
RubyLLM::UnsupportedServerToolError => [:unsupported, :unsupported_capability],
|
|
387
|
+
RubyLLM::ToolCallParseError => [:invalid_response, :invalid_tool_arguments],
|
|
388
|
+
JSON::ParserError => [:invalid_response, :invalid_tool_arguments],
|
|
389
|
+
RubyLLM::ModelNotFoundError => [:configuration, :invalid_configuration],
|
|
390
|
+
RubyLLM::ConfigurationError => [:configuration, :invalid_configuration],
|
|
391
|
+
RubyLLM::CancelledError => [:cancelled, :cancelled]
|
|
392
|
+
}.freeze
|
|
393
|
+
|
|
394
|
+
def self.call(error)
|
|
395
|
+
transient = TRANSIENT.find { |klass, _| error.is_a?(klass) }
|
|
396
|
+
return payload(error, :transient, transient.last, true) if transient
|
|
397
|
+
|
|
398
|
+
permanent = NON_RETRYABLE.find { |klass, _| error.is_a?(klass) }
|
|
399
|
+
return payload(error, *permanent.last, false) if permanent
|
|
400
|
+
|
|
401
|
+
payload(error, :unknown, :unclassified_provider_error, false)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def self.payload(error, category, code, retryable)
|
|
405
|
+
{category: category, code: code, retryable: retryable, message: safe_message(category, code)}
|
|
406
|
+
end
|
|
407
|
+
private_class_method :payload
|
|
408
|
+
|
|
409
|
+
def self.safe_message(category, code)
|
|
410
|
+
"Chat request failed (#{category}/#{code})"
|
|
411
|
+
end
|
|
412
|
+
private_class_method :safe_message
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|