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
9 changes: 9 additions & 0 deletions crates/tinyinference-llm/src/model/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ pub enum ReasoningEffort {
Medium,
/// Above-default effort.
High,
/// Maximum provider-supported effort.
#[serde(rename = "xhigh")]
XHigh,
/// Explicitly disable reasoning.
None,
}
Expand All @@ -96,6 +99,7 @@ impl ReasoningEffort {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::None => "none",
}
}
Expand Down Expand Up @@ -917,6 +921,11 @@ pub enum BlockKind {
/// Tool name.
name: String,
},
/// An opaque provider-defined content block.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Accumulate deltas for provider extensions

The new extension block can be opened, but BlockDelta has no provider-extension payload variant. Incremental extension data therefore cannot be emitted and accumulated through the normalized stream protocol, risking loss of provider content for consumers that rely on deltas. Add an extension delta representation and fold it into the completed block.

[RULE] stream-extension-accumulation ·

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Reconstruct provider-extension deltas before completing the stream

Adding a provider-extension block kind without a corresponding incremental representation leaves extension fragments unable to participate in generic stream reconstruction. When a stream completes without an authoritative response, the accumulator cannot reconstruct the extension content from BlockKind::ProviderExtension; preserve the accumulated extension payload before emitting completion.

[RULE] stream-extension-reconstruction ·

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security likely

Validate provider extension fields before forwarding

This newly exposed provider-extension variant accepts an arbitrary provider-supplied block_type string. Without validating the type and associated payload before it is forwarded through provider adapters, an upstream provider can cause unsupported or malformed extension blocks to be replayed to another provider. Restrict accepted extension types and validate their payload shape at the adapter boundary.

[RULE] provider-extension-validation ·

ProviderExtension {
/// Provider wire type for the extension block.
block_type: String,
},
}

/// An incremental fragment belonging to the open block named in the
Expand Down
26 changes: 18 additions & 8 deletions crates/tinyinference-llm/src/providers/anthropic/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct AnthropicConfig<'a> {
pub temperature_override: Option<f64>,
/// Model-id glob patterns whose targets reject temperature.
pub temperature_unsupported_models: &'a [String],
/// Static headers attached to every request.
pub extra_headers: &'a [(String, String)],
}

impl std::fmt::Debug for AnthropicConfig<'_> {
Expand All @@ -34,6 +36,14 @@ impl std::fmt::Debug for AnthropicConfig<'_> {
"temperature_unsupported_models",
&self.temperature_unsupported_models,
)
.field(
"extra_header_names",
&self
.extra_headers
.iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
)
.finish()
}
}
Expand All @@ -48,12 +58,12 @@ pub fn endpoint_is_anthropic_messages(endpoint: &str) -> bool {

/// Builds an Anthropic Messages model from fully resolved configuration.
pub fn build_anthropic_model(config: AnthropicConfig<'_>) -> Arc<dyn ChatModel<()>> {
Arc::new(
AnthropicModel::with_base_url(config.api_key, config.endpoint)
.with_model(config.model)
.with_temperature_override(config.temperature_override)
.with_temperature_unsupported_models(
config.temperature_unsupported_models.iter().cloned(),
),
)
let mut model = AnthropicModel::with_base_url(config.api_key, config.endpoint)
.with_model(config.model)
.with_temperature_override(config.temperature_override)
.with_temperature_unsupported_models(config.temperature_unsupported_models.iter().cloned());
for (name, value) in config.extra_headers {
model = model.with_header(name.clone(), value.clone());
}
Arc::new(model)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn builds_a_native_anthropic_model_with_the_configured_profile() {
model: "claude-sonnet-4-6",
temperature_override: Some(0.2),
temperature_unsupported_models: &[],
extra_headers: &[],
});
let profile = model.profile().expect("anthropic models expose a profile");
assert_eq!(profile.provider.as_deref(), Some("anthropic"));
Expand All @@ -31,19 +32,23 @@ fn builds_a_native_anthropic_model_with_the_configured_profile() {

#[test]
fn debug_redacts_api_key() {
let headers = vec![("anthropic-beta".to_string(), "secret-beta".to_string())];
let config = AnthropicConfig {
endpoint: "https://endpoint-user:endpoint-pass@api.anthropic.com/v1?token=query-secret#fragment-secret",
api_key: "sk-ant-secret",
model: "claude-sonnet-4-6",
temperature_override: None,
temperature_unsupported_models: &[],
extra_headers: &headers,
};
let debug = format!("{config:?}");
assert!(!debug.contains("sk-ant-secret"));
assert!(!debug.contains("endpoint-user"));
assert!(!debug.contains("endpoint-pass"));
assert!(!debug.contains("query-secret"));
assert!(!debug.contains("fragment-secret"));
assert!(!debug.contains("secret-beta"));
assert!(debug.contains("anthropic-beta"));
assert!(debug.contains("token"));
assert!(debug.contains("[REDACTED]"));
}
43 changes: 38 additions & 5 deletions crates/tinyinference-llm/src/providers/anthropic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub struct AnthropicModel {
/// [`Self::with_temperature_override`].
temperature_override: Option<f64>,
temperature_unsupported: Vec<String>,
extra_headers: Vec<(String, String)>,
allow_insecure_http: bool,
request_options: crate::providers::ProviderRequestOptions,
}
Expand All @@ -108,6 +109,14 @@ impl std::fmt::Debug for AnthropicModel {
.field("profile", &self.profile)
.field("temperature_override", &self.temperature_override)
.field("temperature_unsupported", &self.temperature_unsupported)
.field(
"extra_header_names",
&self
.extra_headers
.iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
)
.field("allow_insecure_http", &self.allow_insecure_http)
.field("request_options", &self.request_options)
.finish()
Expand Down Expand Up @@ -154,6 +163,7 @@ impl AnthropicModel {
model,
temperature_override: None,
temperature_unsupported: Vec::new(),
extra_headers: Vec::new(),
allow_insecure_http: false,
request_options: crate::providers::ProviderRequestOptions::default(),
}
Expand Down Expand Up @@ -196,6 +206,16 @@ impl AnthropicModel {
self
}

/// Attaches a static header to every Messages API request.
///
/// Header values are redacted from [`Debug`](std::fmt::Debug) output.
/// Headers are applied after the built-in Anthropic authentication headers,
/// allowing compatible gateways to override them when necessary.
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_headers.push((name.into(), value.into()));
self
}

/// Replaces the HTTP client, so a host can supply its own transport
/// (platform TLS, proxies, default headers, timeouts).
pub fn with_client(mut self, client: reqwest::Client) -> Self {
Expand Down Expand Up @@ -296,11 +316,24 @@ impl AnthropicModel {
}
self.request_options.apply_payload(&mut body);
let client = self.request_options.http.as_ref().unwrap_or(&self.client);
let request_builder = client
.post(endpoint)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&body);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"x-api-key",
reqwest::header::HeaderValue::from_str(&self.api_key)
.map_err(|e| Error::Validation(e.to_string()))?,
);
headers.insert(
"anthropic-version",
reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION),
);
for (name, value) in &self.extra_headers {
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| Error::Validation(e.to_string()))?;
let value = reqwest::header::HeaderValue::from_str(value)
.map_err(|e| Error::Validation(e.to_string()))?;
headers.insert(name, value);
}
let request_builder = client.post(endpoint).headers(headers).json(&body);
let request_builder = match (streaming, request.timeout_ms) {
(false, Some(timeout_ms)) => request_builder.timeout(Duration::from_millis(timeout_ms)),
_ => request_builder,
Expand Down
30 changes: 22 additions & 8 deletions crates/tinyinference-llm/src/providers/anthropic/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pub(crate) fn request_body(request: &ModelRequest, default_model: &str) -> Value
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
ReasoningEffort::XHigh => "max",
ReasoningEffort::None => unreachable!(),
},
});
Expand Down Expand Up @@ -229,12 +230,11 @@ fn content_blocks(content: &[ContentBlock]) -> Vec<Value> {
ContentBlock::Text(text) => text_block(text),
ContentBlock::Json(value) => text_block(&value.to_string()),
ContentBlock::Image(image) => Some(image_block(image)),
ContentBlock::ProviderExtension(value) => provider_extension_block(value),
Comment thread
senamakel marked this conversation as resolved.
ContentBlock::Document(media) => Some(document_block(media)),
ContentBlock::Audio(media) => Some(unsupported_media_placeholder("audio", media)),
ContentBlock::Video(media) => Some(unsupported_media_placeholder("video", media)),
ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::ProviderExtension(_) => None,
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => None,
})
.collect()
}
Expand All @@ -260,18 +260,32 @@ fn assistant_blocks(content: &[ContentBlock]) -> Vec<Value> {
ContentBlock::RedactedThinking { data } => {
Some(json!({ "type": "redacted_thinking", "data": data }))
}
ContentBlock::ProviderExtension(value) => provider_extension_block(value),
ContentBlock::Thinking {
signature: None, ..
}
| ContentBlock::Image(_)
| ContentBlock::ProviderExtension(_)
| ContentBlock::Audio(_)
| ContentBlock::Video(_)
| ContentBlock::Document(_) => None,
| ContentBlock::Image(_) => None,
ContentBlock::Audio(_) | ContentBlock::Video(_) | ContentBlock::Document(_) => None,
})
.collect()
}

/// Returns an opaque Anthropic content block when it has the object shape the
/// Messages API requires. Keeping the full object intact lets hosts persist and
/// replay newer block types without waiting for a TinyInference release.
fn provider_extension_block(value: &Value) -> Option<Value> {
let object = value.as_object()?;
let block_type = object.get("type").and_then(Value::as_str)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Validate required fields before forwarding extensions

This accepts every unknown block containing only a string type, then forwards it unchanged. For example, {"type":"server_tool_use"} passes validation even though that Anthropic block requires fields such as id, name, and input, so a caller can produce a provider 400 instead of the adapter dropping or rejecting the malformed block. Validate the required shape for each supported extension type before returning it, and reject known native types such as tool_result that should use normalized representations.

[RULE] validate-provider-extension ·

// Provider extensions are for block types this adapter does not model.
// Rejecting native types prevents callers from bypassing their normalized
// representations with incomplete provider-shaped JSON.
(!matches!(
block_type,
"text" | "image" | "document" | "tool_use" | "thinking" | "redacted_thinking"
))
.then(|| value.clone())
}

/// Renders an image reference: a `data:` URI becomes an inline base64 source,
/// anything else a URL source.
fn image_block(image: &ImageRef) -> Value {
Expand Down
6 changes: 1 addition & 5 deletions crates/tinyinference-llm/src/providers/anthropic/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,7 @@ pub(crate) fn parse_response(body: Value) -> Result<ModelResponse> {
"redacted_thinking" => content.push(ContentBlock::RedactedThinking {
data: required_string(block.get("data"), "content[].data")?.to_string(),
}),
other => {
return Err(malformed(&format!(
"unsupported content block type: {other}"
)));
}
_ => content.push(ContentBlock::ProviderExtension(block.clone())),
}
}
Ok(ModelResponse {
Expand Down
48 changes: 47 additions & 1 deletion crates/tinyinference-llm/src/providers/anthropic/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ enum OpenBlock {
signature: Option<String>,
},
Redacted(String),
ProviderExtension {
value: Value,
partial_json: String,
},
}

impl OpenBlock {
Expand Down Expand Up @@ -87,10 +91,23 @@ impl OpenBlock {
"arguments": arguments,
}))
}
OpenBlock::ProviderExtension {
value,
partial_json,
} => ContentBlock::ProviderExtension(provider_extension_value(value, partial_json)),
}
}
}

fn provider_extension_value(mut value: Value, partial_json: String) -> Value {
if !partial_json.trim().is_empty()
&& let Ok(input) = serde_json::from_str::<Value>(&partial_json)
{
value["input"] = input;
}
value
}

/// Provider-side accumulator rebuilding the terminal [`ModelResponse`].
#[derive(Debug, Default)]
struct AnthropicStreamAcc {
Expand Down Expand Up @@ -201,7 +218,7 @@ impl AnthropicStreamAcc {
});
OpenBlock::Redacted(block["data"].as_str().unwrap_or_default().to_string())
}
_ => {
Some("text") => {
pending.push_back(ModelStreamItem::BlockStart {
index,
kind: BlockKind::Text,
Expand All @@ -218,6 +235,18 @@ impl AnthropicStreamAcc {
}
OpenBlock::Text(text)
}
_ => {
pending.push_back(ModelStreamItem::BlockStart {
index,
kind: BlockKind::ProviderExtension {
block_type: block["type"].as_str().unwrap_or_default().to_string(),
},
});
OpenBlock::ProviderExtension {
value: block.clone(),
partial_json: String::new(),
}
}
};
*self.slot(index) = Some(open);
}
Expand Down Expand Up @@ -258,6 +287,13 @@ impl AnthropicStreamAcc {
content_index: Some(index),
}));
}
(
Some("input_json_delta"),
Some(OpenBlock::ProviderExtension { partial_json, .. }),
) => {
let fragment = delta["partial_json"].as_str().unwrap_or_default();
partial_json.push_str(fragment);
Comment on lines +294 to +295

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit extension argument fragments to stream consumers

When an Anthropic server_tool_use streams its arguments through input_json_delta, this branch only appends each fragment to the private accumulator and emits no ModelStreamItem. Consequently, block-aware consumers see BlockStart followed by no incremental arguments until BlockEnd, even though ordinary tool_use blocks expose every fragment immediately; add an opaque extension delta or otherwise surface these fragments so live consumers can preserve them.

AGENTS.md reference: AGENTS.md:L46-L48

Useful? React with 👍 / 👎.

}
(Some("thinking_delta"), Some(OpenBlock::Thinking { text, .. })) => {
let fragment = delta["thinking"].as_str().unwrap_or_default();
text.push_str(fragment);
Expand Down Expand Up @@ -339,6 +375,15 @@ impl AnthropicStreamAcc {
content.push(ContentBlock::Thinking { text, signature });
}
OpenBlock::Redacted(data) => content.push(ContentBlock::RedactedThinking { data }),
OpenBlock::ProviderExtension {
value,
partial_json,
} => {
content.push(ContentBlock::ProviderExtension(provider_extension_value(
value,
partial_json,
)));
}
OpenBlock::ToolUse {
id,
name,
Expand Down Expand Up @@ -371,6 +416,7 @@ impl AnthropicStreamAcc {
"stop_reason": self.stop_reason,
"content": content.iter().filter_map(|block| match block {
ContentBlock::Text(text) => Some(serde_json::json!({"type": "text", "text": text})),
ContentBlock::ProviderExtension(value) => Some(value.clone()),
_ => None,
}).chain(tool_calls.iter().map(|call| serde_json::json!({
"type": "tool_use", "id": call.id, "name": call.name, "input": call.arguments,
Expand Down
Loading
Loading