Skip to content
Draft
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
2 changes: 1 addition & 1 deletion crates/llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ bytes.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { version = "=1.53.1", default-features = false, features = ["rt", "fs", "io-util", "sync", "time"] }
tokio = { version = "=1.53.1", default-features = false, features = ["rt", "fs", "io-util", "macros", "sync", "time"] }
oauth2 = { version = "5.0.0", default-features = false, features = ["timing-resistant-secret-traits"] }
sha2.workspace = true
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
Expand Down
30 changes: 29 additions & 1 deletion crates/llm/README.org
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ Use =Client::embed= with an explicit OpenAI-compatible embeddings URL for
batches of text or token IDs. The client returns one float vector per input
in input order and checks the returned dimensions.

For Anthropic Messages, a buffered assistant message carries its ordered
=anthropic_output= content blocks, including signed or redacted thinking. For
streaming turns, attach the terminal event's =anthropic_output= to the assistant
message before continuing. Weaver checks that the visible text and tool calls
still match those blocks. Debug formatting redacts the block contents. The
caller must retain this data with the conversation; Weaver does not compress it.

The client distinguishes rate limits, context length errors and temporary
upstream failures. For HTTP rate limits it exposes integer seconds from
=Retry-After= when available. These errors inform caller decisions.
Expand Down Expand Up @@ -186,7 +193,16 @@ env = "OPENAI_API_KEY"

Supported protocol names are =openai-chat-completions=, =openai-responses=
and =anthropic-messages=. Both the profile and configuration path come from
trusted Harness startup settings. Supply the chosen model explicitly.
trusted Harness startup settings. For an Anthropic direct profile, explicit
=model= and =endpoint= win; missing fields use =ANTHROPIC_MODEL= and
=ANTHROPIC_BASE_URL=. The base URL is expanded to =/v1/messages=, preserving
an existing =/v1= or =/v1/messages= suffix. An omitted credential uses
=ANTHROPIC_API_KEY=, which is included in =credential_environment()=. Other
protocols still require explicit model, endpoint and credential. Environment
values are resolved at =prepare= and the resulting profile is immutable.
An Anthropic-compatible environment-backed profile with an optional OpenAI
alternative is provided in [[file:example.toml][example.toml]]; callers select =anthropic=
when invoking =prepare=. The client never compresses conversation context.
The optional profile field =authentication = "bearer"= selects an explicit
Authorization Bearer header. Its default, =provider_default=, uses Bearer for
OpenAI and =x-api-key= for Anthropic API keys. Anthropic =sk-ant-oat= access
Expand Down Expand Up @@ -665,3 +681,15 @@ These finite models and sampled traces check the declared abstraction; they
are not an exhaustive proof of the Rust implementation or external IO atomicity.
All five models run through the existing CI plan. Rust properties run in the normal
client Cargo tests, with no separate test runner or cold dependency install.

** Anthropic thinking controls and stream events

=Options::anthropic_thinking= selects adaptive, manual budget or disabled
thinking, and =anthropic_effort= selects output effort. Availability varies by
model and provider. Manual thinking requires at least 1024 tokens and a larger
=max_tokens=; DeepSeek documents that it ignores the manual budget. Other
protocols reject these Anthropic-specific options. Omitted controls defer to the
provider. =TurnEvent::Thinking= yields provider-returned thinking separately
from final-answer text. Its debug representation omits the content; callers
choose whether to display or store it. Terminal =anthropic_output= remains
necessary for lossless continuation, including signatures and redacted blocks.
1 change: 1 addition & 0 deletions crates/llm/benches/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fn request_encoding(c: &mut Criterion) {
tool_call_id: None,
tool_calls: Vec::new(),
responses_output: None,
anthropic_output: None,
}];
let request = Request {
model: "gpt-example",
Expand Down
28 changes: 28 additions & 0 deletions crates/llm/example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors
#
# SPDX-License-Identifier: LGPL-2.1-or-later AND Apache-2.0

# Anthropic-compatible route. Missing fields use ANTHROPIC_MODEL,
# ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY at profile preparation.
[profiles.anthropic]
kind = "direct"
protocol = "anthropic-messages"
timeout_ms = 120000
max_response_bytes = 8388608

# Explicit settings take precedence over environment variables.
# model = "deepseek-v4-pro[1m]"
# endpoint = "https://your-provider.example/v1/messages"
# [profiles.anthropic.credential]
# env = "ANTHROPIC_API_KEY"

# Optional OpenAI profile:
# [profiles.openai]
# kind = "direct"
# protocol = "openai-responses"
# endpoint = "https://api.openai.com/v1/responses"
# model = "gpt-4.1-mini"
# timeout_ms = 120000
# max_response_bytes = 8388608
# [profiles.openai.credential]
# env = "OPENAI_API_KEY"
99 changes: 99 additions & 0 deletions crates/llm/src/anthropic_output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors
//
// SPDX-License-Identifier: LGPL-2.1-or-later AND Apache-2.0

//! Opaque Anthropic assistant output for lossless continuation.

use crate::{Error, Message, Role, ToolCall};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt;

/// Ordered provider content blocks, including signed or redacted thinking.
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(transparent)]
pub struct AnthropicOutput(pub(crate) Vec<Value>);

impl fmt::Debug for AnthropicOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AnthropicOutput")
.field("block_count", &self.0.len())
.finish()
}
}

impl AnthropicOutput {
pub(crate) fn parse(blocks: Vec<Value>) -> Result<(Self, String, Vec<ToolCall>), Error> {
let (text, calls) = Self::extract(&blocks)?;
Ok((Self(blocks), text, calls))
}

fn extract(blocks: &[Value]) -> Result<(String, Vec<ToolCall>), Error> {
let mut text = String::new();
let mut calls = Vec::new();
for block in blocks {
match block["type"].as_str() {
Some("text") => text.push_str(block["text"].as_str().ok_or(Error::Response)?),
Some("thinking")
if block["thinking"].is_string()
&& (block.get("signature").is_none() || block["signature"].is_string()) => {
}
Some("redacted_thinking") if block["data"].is_string() => {}
Some("tool_use") => {
let id = block["id"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or(Error::Response)?;
let name = block["name"]
.as_str()
.filter(|s| !s.is_empty())
.ok_or(Error::Response)?;
let arguments = block["input"].as_object().ok_or(Error::Response)?;
calls.push(ToolCall {
id: id.into(),
name: name.into(),
arguments: Value::Object(arguments.clone()),
});
}
_ => return Err(Error::Response),
}
}
Ok((text, calls))
}

/// Check observed answer and thinking against the retained provider blocks.
///
/// This checks consistency, not the authenticity of a provider signature.
/// Redacted thinking remains opaque and is preserved in the continuation.
pub fn matches_observed(&self, text: &str, thinking: &str) -> bool {
let Ok((output_text, calls)) = Self::extract(&self.0) else {
return false;
};
if output_text != text || !calls.is_empty() {
return false;
}
let mut remaining = thinking;
for block in &self.0 {
if block["type"].as_str() == Some("thinking") {
let Some(part) = block["thinking"].as_str() else {
return false;
};
let Some(rest) = remaining.strip_prefix(part) else {
return false;
};
remaining = rest;
}
}
remaining.is_empty()
}

pub(crate) fn matches(&self, message: &Message) -> bool {
if message.role != Role::Assistant || !message.images.is_empty() {
return false;
}
match Self::extract(&self.0) {
Ok((text, calls)) => text == message.content && calls == message.tool_calls,
Err(_) => false,
}
}
}
43 changes: 43 additions & 0 deletions crates/llm/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,49 @@ impl Endpoint {
})
}

/// Fingerprint the configured destination and credential for continuation binding.
///
/// The fingerprint is sensitive: it permits guessing low-entropy credentials.
/// It is not proof of server identity or account identity. Credential rotation
/// changes it, and callers must not silently rebind existing continuations.
pub fn continuation_binding(&self, credential: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
fn part(hasher: &mut Sha256, value: &[u8]) {
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(value);
}
part(&mut hasher, b"weaver.continuation.destination.v1");
part(&mut hasher, self.url.as_str().as_bytes());
part(
&mut hasher,
match self.protocol {
Protocol::OpenAi => b"openai-chat",
Protocol::OpenAiResponses => b"openai-responses",
Protocol::Anthropic => b"anthropic-messages",
},
);
part(
&mut hasher,
match self.authentication {
Authentication::ProviderDefault => b"provider-default",
Authentication::Bearer => b"bearer",
},
);
part(&mut hasher, credential.as_bytes());
let mut headers: Vec<_> = self
.headers
.iter()
.map(|(name, value)| (name.as_str(), value.as_bytes()))
.collect();
headers.sort_by(|left, right| left.0.cmp(right.0));
for (name, value) in headers {
part(&mut hasher, name.as_bytes());
part(&mut hasher, value);
}
hasher.finalize().into()
}

/// Bind an explicit authentication scheme without changing the wire protocol.
pub fn with_authentication(mut self, authentication: Authentication) -> Self {
self.authentication = authentication;
Expand Down
Loading
Loading