Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

- **Bedrock, Gemini and Vertex no longer send an empty message** (#369). An assistant turn that errored, was refused or was cancelled with no content (the loop's error message is one empty text block, which the builders filter) — or whose only content is thinking the provider does not replay (on Gemini and Vertex any thinking; on Bedrock unsigned reasoning to a Claude model, or another API's) — went out as `content: []` / `parts: []`, which both APIs reject (as did a user message with only empty text); the Anthropic provider already skipped such an assistant turn. So the prompt after a failed or cancelled run was rejected too. Empty messages of either role are now dropped before consecutive same-role messages are merged (#368), so the user turns around a dropped one go back as one.
- **Bedrock, Gemini and Vertex: a parallel tool call's results go back in one message** (#223). Each tool result was sent as its own user-role message, and a steering message, stop marker or compaction summary after them added another, while Bedrock's Converse requires roles to alternate and Gemini requires a turn's function responses in one content, as many as its function calls. So the request after any turn with two or more tool calls (the default parallel strategy) could be rejected. The three request builders now merge consecutive same-role messages, keeping every block in order: the results in call order, then whatever followed; on Gemini and Vertex a merged turn's function responses come first, then any images and text (images among the responses are a reported cause of Gemini's response-count 400). Pinned by request-body tests; not run against a live endpoint.

## 0.25.12 (2026-10-21)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The Responses and Azure providers share one request builder + read loop (`respon

Anthropic SSE (`anthropic.rs`): block `index` → position in `content` via a map, never padding, so a block type it does not surface (server tool blocks, `fallback`, anything new) leaves no placeholder and later blocks keep their deltas; unknown block/delta types `warn_once` per type by name, unparseable start/delta events are logged. `redacted_thinking` (`data` arrives whole at `content_block_start`, no delta type) → `Content::thinking_redacted(ApiProtocol::AnthropicMessages, data)` → replayed as `{"type":"redacted_thinking","data"}` in place (#197).

Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Consecutive same-role messages are merged before sending (`provider::merge_same_role(messages, "content")`; Gemini and Vertex do the same with `"parts"`, then `google::function_responses_first` puts each user content's `functionResponse` parts before its images and text), so a parallel tool call's results go back as one user message and roles alternate (#223). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips].<region>.<suffix>`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint.
Bedrock is the one non-SSE provider: ConverseStream answers with binary `application/vnd.amazon.eventstream` frames, decoded by the private `provider/eventstream.rs` (bytes buffered across chunks; prelude + message CRC-32 verified with an in-house table, no dependency; all ten header types; checksum/malformed → `Other`, a body ending mid-frame → `Network`; pinned by aws-sdk-go's published test vectors). `bedrock.rs` dispatches on the `:message-type` / `:event-type` / `:exception-type` headers — payloads are the event structs themselves (no wrapper key), shapes per the AWS Bedrock Runtime API reference. Blocks accumulate per `contentBlockIndex`; a tool call is pushed with the `__partial_json` marker and only `contentBlockStop` finalizes it (`finalize_tool_arguments`), so an unclosed block is never runnable. Exceptions classify via their documented HTTP status (`throttlingException`/`serviceUnavailableException` → `RateLimited`; other 5xx stay `Api`). No `messageStop` (incl. empty body) or a transport error → `Network`, never an empty `Ok` — except once `messageStop` **and** `metadata` arrived: a later drop/truncation keeps the response (no re-billed retry). `messageStop` without `metadata` → zero usage + `warn!(usage_missing = true)` (same zero as other providers). Unsurfaced content (image/citation/toolResult, server_tool_use, unknown union members via `#[serde(flatten)] other`, unknown event types) is dropped with a once-per-block warning; an unknown `contentBlockStart` member creates no block, so the first delta types it. Reasoning replay: signed → `reasoningText`, `redactedContent` kept in `Content::Thinking::redacted` (base64, bytes concatenated across deltas; `redacted_protocol = BedrockConverseStream`) → `redactedContent`, only if it came from Bedrock (another API's redacted data is skipped, logged at debug); unsigned → `reasoningText {text}` with no signature key, except to a model id containing `claude` (signature-verifying), where it is skipped at debug (never `signature: ""`); the Anthropic provider skips Bedrock's redacted blocks. Signature deltas are appended. Tested only with mock frames (`tests/bedrock_stream_test.rs`). Consecutive same-role messages are merged before sending (`provider::merge_same_role(messages, "content")`; Gemini and Vertex do the same with `"parts"`, then `google::function_responses_first` puts each user content's `functionResponse` parts before its images and text), so a parallel tool call's results go back as one user message and roles alternate (#223); a message whose array is empty (an errored, refused or cancelled turn, or one with only unreplayable thinking; either role) is dropped first and its neighbours merge (#369). Auth (`resolve_auth`, first match wins): an `authorization` key in `ModelConfig.headers` (any case) → sent alone; non-empty `api_key` → `access:secret[:token]` = SigV4 (`ASIA…` without a token refused), no `:` = Bedrock API key (`Bearer`) after `checked_bearer` refuses whitespace/control chars, an `AKIA`/`ASIA` start, non-base64 chars and a bare 40-char secret (unknown prefix — not `bedrock-api-key-`/`ABSK` — only warns once); empty `api_key` → env read per request (type `Env` = `std::env::var`-shaped, faked in tests), `AWS_BEARER_TOKEN_BEDROCK` (same checks) then `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`); half a pair / NotUnicode / none → `Auth` naming the variable, before sending. `resolve_api_key_or_warn(Option<&ModelConfig>)` (Agent, SubAgentTool, LlmCompaction) keys on the **protocol**: any `BedrockConverseStream` config gets an empty key (never `API_KEY`/`YOAGENT_API_KEY`, whatever the provider string), so the provider reads env itself; `resolve_api_key("bedrock")` (public) prefers `AWS_BEARER_TOKEN_BEDROCK`. `request_headers` builds the whole `HeaderMap`: every value via `header_value` (credential values must be ASCII; failure → `Auth` naming the header, never the value; user non-auth header → `Other`), `authorization`/`x-amz-security-token` `set_sensitive`; on the SigV4 path a user header named like a signed one is refused; a `send()` `is_builder()` error → `Other`, not `Network`. SigV4 is the private `provider/sigv4.rs` (sha2 + hmac, no AWS SDK; pinned to aws-c-auth's `aws-sig-v4-test-suite` vectors incl. the `*-normalized` path ones): signing name `bedrock`, region from the host (`bedrock-runtime[-fips].<region>.<suffix>`, suffix ∈ `AWS_DNS_SUFFIXES` = partition dnsSuffix/dualStackDnsSuffix from botocore + VPC forms) > `AWS_REGION` > `AWS_DEFAULT_REGION` > `Auth` error; signs `host`, `content-type`, `x-amz-date`, `x-amz-content-sha256`, `x-amz-security-token` over the exact body bytes sent (serialize once — never `.json()`); `amz_date_now` errors on a pre-1970 clock; a 403 "Signature expired"/"not yet current" gets a check-the-clock hint. `base_url` is `trim_end_matches('/')`-ed; `canonical_uri` normalizes (drops empty/`.` segments, resolves `..`, keeps a trailing `/`) and encodes each segment again, so the model id (`:` → `%3A` on the wire, as the SDKs send it) is `%253A` in the canonical URI. `tests/bedrock_auth_test.rs` (env-mutating, serialized by a tokio `Mutex`) recomputes each signature from what wiremock received and uses `expect(0)` for every refusal. Never run against a live endpoint.

`ModelConfig` + quirk structs handle per-provider differences: `OpenAiCompat` (reasoning format, max_tokens field name, `max_reasoning_effort` ceiling — the ceiling, and `encrypted_reasoning`, are also read by Responses/Azure), `AnthropicCompat` (adaptive vs budget thinking, bearer auth, `native_structured_output`; `for_claude_id` infers them from an id), `GoogleCompat` (`thinkingLevel` vs `thinkingBudget`). `ThinkingLevel::Off` sends no thinking/effort field on any provider (DeepSeek's explicit `thinking: disabled` aside), so always-thinking models run at their default.

Expand Down
2 changes: 1 addition & 1 deletion docs/providers/bedrock.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ is kept in `Content::Thinking::redacted` (with `redacted_protocol` set to
`BedrockConverseStream`) and sent back as `redactedContent`. Encrypted
reasoning from another API is not: AWS does not document `redactedContent` as
the same bytes as the Anthropic API's `redacted_thinking` data, so an Anthropic
redacted block is skipped here with a warning (and Bedrock's is skipped by the
redacted block is skipped here, logged at debug (and Bedrock's is skipped by the
Anthropic provider).
Reasoning without a signature is sent back as `reasoningText {text}` with no
signature key (the signature is optional in `ReasoningTextBlock`, and Bedrock's
Expand Down
25 changes: 25 additions & 0 deletions src/provider/bedrock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1611,6 +1611,31 @@ impl TokenUsage {
mod tests {
use super::*;

/// An errored assistant turn — as the loop builds it, one empty text block,
/// or with no content at all — is not sent as an empty message; the user
/// turns around it go back as one (#369).
#[test]
fn an_empty_assistant_turn_is_not_sent() {
for errored in [
vec![Content::Text {
text: String::new(),
}],
vec![],
] {
let mut config = StreamConfig::new("anthropic.claude-sonnet", "a:b");
config.messages = vec![
Message::user("first"),
Message::assistant(errored, StopReason::Error, "m", "bedrock", Usage::default()),
Message::user("again"),
];
let body = build_bedrock_body(&config);
assert_eq!(
body["messages"],
serde_json::json!([{"role": "user", "content": [{"text": "first"}, {"text": "again"}]}])
);
}
}

/// A parallel tool call's results go back as one user message, the
/// steering message after them, so Converse's roles alternate (#223).
#[test]
Expand Down
25 changes: 25 additions & 0 deletions src/provider/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,31 @@ mod tests {
use super::*;
use crate::provider::{GoogleCompat, ModelConfig};

/// A model turn whose only content is thinking (not replayed), or the
/// loop's error message (one empty text block), is not sent as an empty
/// content (#369).
#[test]
fn an_empty_model_turn_is_not_sent() {
for turn in [
vec![Content::thinking("thought")],
vec![Content::Text {
text: String::new(),
}],
] {
let mut config = StreamConfig::new("gemini-2.5-pro", "k");
config.messages = vec![
Message::user("first"),
Message::assistant(turn, StopReason::Error, "m", "google", Usage::default()),
Message::user("again"),
];
let body = build_request_body(&config);
assert_eq!(
body["contents"],
serde_json::json!([{"role": "user", "parts": [{"text": "first"}, {"text": "again"}]}])
);
}
}

/// A parallel tool call's results go back as one user turn, with the
/// steering message after them, so roles alternate (#223).
#[test]
Expand Down
23 changes: 23 additions & 0 deletions src/provider/google_vertex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,29 @@ mod tests {
);
}

/// A model turn with only thinking (not replayed) is not sent as an empty
/// content; the user turns around it go back as one (#369).
#[test]
fn an_empty_model_turn_is_not_sent() {
let mut c = config(ThinkingLevel::Off);
c.messages = vec![
Message::user("first"),
Message::assistant(
vec![Content::thinking("thought")],
crate::types::StopReason::Error,
"m",
"vertex",
crate::types::Usage::default(),
),
Message::user("again"),
];
let body = build_vertex_request_body(&c);
assert_eq!(
body["contents"],
serde_json::json!([{"role": "user", "parts": [{"text": "first"}, {"text": "again"}]}])
);
}

/// A parallel tool call's results go back as one user turn (#223).
#[test]
fn parallel_tool_results_go_back_in_one_turn() {
Expand Down
Loading
Loading