Skip to content
Open
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ The workspace provides:
- OpenAI Chat Completions, OpenAI Responses, and OpenAI-compatible provider
adapters for Anthropic, Ollama, DeepSeek, Groq, xAI, OpenRouter, Together,
and Mistral;
- native Perplexity Agent API model/preset selection, cited and hosted-tool
output, incremental streaming, signed function continuation, background
recovery, and generated-file retrieval;
- OpenAI, Cohere, Ollama, Voyage, cloud, no-op, and deterministic mock
embeddings;
- standalone document reranking through Voyage, with original-position results,
Expand Down Expand Up @@ -64,6 +67,43 @@ standards and OpenRouter media transport,
download, resume), `tinyinference-decisions` for typed Jev and Sage decisions,
and `tinyinference-core` only for shared infrastructure.

### Perplexity Agent API

Use one Perplexity key with an explicit model or server-managed preset:

```rust,no_run
use tinyinference_llm::{ChatModel, Message, ModelRequest, PerplexityModel, PerplexitySelection};

# async fn example(key: String) -> tinyinference_llm::Result<()> {
let model = PerplexityModel::new(key, PerplexitySelection::preset("low"))?;
let response = model.invoke(&(), ModelRequest::new(vec![
Message::user("Explain the latest battery research with sources."),
])).await?;
println!("{}", response.text());
// response.output contains typed citations, hosted results and file notices.
# Ok(())
# }
```

`PerplexitySelection::Model("provider/model".into())` selects a specific model;
`Models` configures a provider-managed fallback list. Presets retain server
defaults unless you explicitly override them. Tool settings merge with preset
tools on the provider. Explicit Anthropic models require an output-token cap.

`ModelResponse.output` preserves ordered rich output; `message` is the familiar
text/reasoning/local-function projection. Hosted MCP, search and sandbox activity
is never dispatched as a local function call. Stream-only progress survives in
`execution.progress`; incomplete and cancelled states remain explicit.

Use `submit_background`, `retrieve_response`, `resume_background`, and
`cancel_background` for long jobs. List and explicitly download generated files
by response/file ID; filenames are metadata, not local paths. A disconnect never
silently starts a replacement run. Remote MCP credentials are bound to their
configured HTTPS server and kept out of serializable model requests.

See [Perplexity contracts and migration](docs/migrations/perplexity-agent.md)
for configuration, replay, storage semantics, limits, and public Rust type changes.

### Voyage reranking

`tinyinference-embeddings` exports `Reranker`, `RerankRequest`,
Expand Down
4 changes: 3 additions & 1 deletion crates/tinyinference-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ bytes = { workspace = true }
futures = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
sha2 = { workspace = true }
thiserror = { workspace = true }
tinyinference-core = { version = "0.3.0", path = "../tinyinference-core" }
Expand All @@ -30,6 +30,8 @@ tokio = { workspace = true }
tracing = { workspace = true }

[dev-dependencies]
http = "1"
tokio = { workspace = true, features = ["test-util"] }
tempfile = { workspace = true }

[lints]
Expand Down
66 changes: 66 additions & 0 deletions crates/tinyinference-llm/examples/perplexity_function_replay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! Run a Google function call and replay its result through Perplexity.
//!
//! Set `PERPLEXITY_API_KEY`, then run:
//! `cargo run -p tinyinference-llm --example perplexity_function_replay`.
//! This makes two billable requests and runs one local demonstration function.

use serde_json::json;
use tinyinference_llm::{
ChatModel, ContentBlock, ExecutionStatus, Message, ModelRequest, PerplexityModel,
PerplexitySelection, ToolSchema, message::ToolMessage, model::ToolChoice,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("PERPLEXITY_API_KEY")?;
let model = PerplexityModel::new(
key,
PerplexitySelection::Model("google/gemini-3.1-flash-lite".into()),
)?;
let tool = ToolSchema::new(
"lookup_code",
"Return the verification code for a record",
json!({
"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"],
"additionalProperties": false
}),
);
let mut request = ModelRequest::new(vec![Message::user(
"Use lookup_code for record 7, then tell me its verification code.",
)])
.with_tools(vec![tool])
.with_tool_choice(ToolChoice::Tool("lookup_code".into()))
.with_max_tokens(512);
request.timeout_ms = Some(60_000);
let first = model.invoke(&(), request.clone()).await?;
let execution = first.execution.as_ref().ok_or("missing model identity")?;
if execution.status != ExecutionStatus::Completed || first.message.tool_calls.is_empty() {
return Err("expected a completed function call".into());
}

// Replay the original assistant call so its argument bytes and signature survive.
request
.messages
.push(Message::Assistant(first.message.clone()));
for call in &first.message.tool_calls {
if call.invalid.is_some()
|| call.name != "lookup_code"
|| call.arguments.get("id").and_then(serde_json::Value::as_u64) != Some(7)
{
return Err("unexpected function or invalid arguments".into());
}
let result = ToolMessage::for_call(call, vec![ContentBlock::Text("CODE_7391".into())]);
request.messages.push(Message::Tool(result));
}
request.model = Some(execution.model.clone());
request.continuation_id = None;
request.tool_choice = ToolChoice::None;
let response = model.invoke(&(), request).await?;
println!("{}", response.text());
if !response.text().contains("CODE_7391") {
return Err("the model did not return the demonstration function result".into());
}
Ok(())
}
2 changes: 2 additions & 0 deletions crates/tinyinference-llm/src/failure_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ fn structured_status_takes_precedence_over_message_heuristics() {
#[test]
fn structured_provider_error_uses_the_same_classifier() {
let error = ProviderError {
partial_response: None,
status: Some(429),
code: Some("insufficient_quota".into()),
message: "quota exhausted".into(),
Expand All @@ -68,6 +69,7 @@ fn structured_provider_error_uses_the_same_classifier() {
#[test]
fn normalized_provider_retryability_is_authoritative() {
let error = ProviderError {
partial_response: None,
message: "malformed streaming payload".into(),
retryable: false,
..ProviderError::default()
Expand Down
12 changes: 12 additions & 0 deletions crates/tinyinference-llm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,23 @@ pub use failure::{
provider_error_is_retryable, structured_http_status,
};
pub use message::{AssistantMessage, ContentBlock, Message, MessageDelta};
pub use model::{
AnnotatedContent, ExecutionStatus, FetchedContent, FinanceResult, HostedToolUsage,
ImageSearchResult, ModelExecution, ModelFile, ModelFileContent, ModelOutputEvent,
ModelOutputItem, ModelOutputKind, ModelProgress, OutputAnnotation, RemoteToolDefinition,
RemoteToolNamespace, ReportedCost, SearchSource,
};
pub use model::{
ChatModel, InputModality, InputSource, ModelRequest, ModelResponse, ModelStream,
ModelStreamItem, context_window_for_model_id, model_id_supports_vision,
};
pub use network_guard::{allow_network_models, deny_network_models, network_models_denied};
pub use providers::perplexity::{
PerplexityConfig, PerplexityImageFilters, PerplexityLocation, PerplexityModel,
PerplexityOptions, PerplexityRemoteCredentials, PerplexitySearchFilters, PerplexitySelection,
PerplexityTool, PerplexityToolChoice, PerplexityWebSearch, build_perplexity_model,
};
pub use providers::{MockModel, ProviderKind, ProviderSpec};
pub use tool::{ToolCall, ToolDelta, ToolFormat, ToolSchema};
pub use tool::{ToolCallReplay, ToolResultContext};
pub use usage::{Usage, UsageTotals};
1 change: 1 addition & 0 deletions crates/tinyinference-llm/src/message/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ impl Message {
/// Creates a tool result message for the given tool call id.
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Message::Tool(ToolMessage {
call_context: None,
tool_call_id: tool_call_id.into(),
content: vec![ContentBlock::Text(content.into())],
trusted_verbatim: false,
Expand Down
19 changes: 19 additions & 0 deletions crates/tinyinference-llm/src/message/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ pub struct MessageOrigin {
/// A tool result message correlated to a prior tool call.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolMessage {
/// Name and signed-call context for stateful native function results.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call_context: Option<crate::tool::ToolResultContext>,

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 critical security confident

Update all ToolMessage literals for the new required field

Adding a field to this public struct breaks existing struct literals that construct ToolMessage without call_context (including production and test callers elsewhere in the crate). Rust requires every struct literal to specify the new field, so the crate will fail to compile as written. Update all callers in the same change, or redesign this API so adding context does not require every literal to change.


Additional critique observation

priority critical confident

Preserve compatibility for existing ToolMessage literals

[RULE] breaking-api

Adding a field to this public struct requires every existing struct literal to initialize it. The repository still has ToolMessage { ... } literals that do not provide call_context, including production code, so the workspace will fail to compile; downstream callers using the public struct literal syntax break as well. Update all construction sites (using call_context: None where appropriate), or provide a compatibility-preserving construction/API strategy before merging.

[RULE] breaking-public-struct-change ·

/// Id of the tool call this message answers.
pub tool_call_id: String,
/// Ordered content blocks.
Expand All @@ -355,6 +358,22 @@ pub struct ToolMessage {
pub artifact: Option<Value>,
}

impl ToolMessage {
/// Creates a native function result retaining the originating call context.
pub fn for_call(call: &ToolCall, content: Vec<ContentBlock>) -> Self {
Self {
call_context: Some(crate::tool::ToolResultContext {
name: call.name.clone(),
replay: call.replay.clone(),
}),
tool_call_id: call.id.clone(),
content,
trusted_verbatim: false,
artifact: None,
}
}
}

fn is_false(value: &bool) -> bool {
!*value
}
Expand Down
6 changes: 6 additions & 0 deletions crates/tinyinference-llm/src/model/migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ async fn every_terminal_stream_item_disarms_its_abort_guard() {
ModelStreamItem::Completed(ModelResponse::assistant("done")),
ModelStreamItem::Failed("failed".to_string()),
ModelStreamItem::ProviderFailed(ProviderError {
partial_response: None,
provider: "mock".to_string(),
message: "provider failed".to_string(),
..ProviderError::default()
Expand Down Expand Up @@ -359,6 +360,8 @@ async fn observer_reports_each_terminal_outcome_once() {

let cached = ObservingModel::new(
Arc::new(RecordingModel::new(ModelResponse {
output: Vec::new(),
execution: None,
served_from_cache: true,
..ModelResponse::assistant("cached")
})),
Expand Down Expand Up @@ -449,6 +452,8 @@ async fn observer_streams_report_one_terminal_outcome_with_stream_metadata() {
let cached = ObservingModel::new(
Arc::new(ScriptedStreamModel::new(
vec![ModelStreamItem::Completed(ModelResponse {
output: Vec::new(),
execution: None,
served_from_cache: true,
..ModelResponse::assistant("cached")
})],
Expand All @@ -466,6 +471,7 @@ async fn observer_streams_report_one_terminal_outcome_with_stream_metadata() {
let provider_failed = ObservingModel::new(
Arc::new(ScriptedStreamModel::new(
vec![ModelStreamItem::ProviderFailed(ProviderError {
partial_response: None,
provider: "mock".to_string(),
message: "provider failed".to_string(),
..ProviderError::default()
Expand Down
35 changes: 35 additions & 0 deletions crates/tinyinference-llm/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

mod decorators;
pub mod discover;
mod output;
mod types;

#[cfg(test)]
Expand All @@ -29,6 +30,7 @@ use crate::tool::{ToolCall, ToolSchema};
use crate::usage::Usage;

pub use decorators::*;
pub use output::*;
pub use types::*;

/// How a context-window pattern is matched against a model id.
Expand Down Expand Up @@ -584,6 +586,8 @@ impl ModelResponse {
/// Creates a response wrapping a plain assistant text message.
pub fn assistant(content: impl Into<String>) -> Self {
Self {
output: Vec::new(),
execution: None,
message: AssistantMessage {
id: None,
content: vec![ContentBlock::Text(content.into())],
Expand Down Expand Up @@ -680,6 +684,9 @@ impl ModelResponse {
/// usage.
#[derive(Clone, Debug, Default)]
pub struct StreamAccumulator {
output: std::collections::BTreeMap<usize, ModelOutputItem>,
execution: Option<ModelExecution>,
progress: Vec<ModelProgress>,
/// Concatenated text fragments.
text: String,
/// Accumulated reasoning/thinking fragments (side channel; not merged into
Expand Down Expand Up @@ -714,6 +721,16 @@ impl StreamAccumulator {
/// Folds one stream item into the running state.
pub fn push(&mut self, item: &ModelStreamItem) {
match item {
ModelStreamItem::OutputEvent(event) => match event {
ModelOutputEvent::Execution(execution) => {
self.execution = Some((**execution).clone())
}
ModelOutputEvent::Item(item) => {
self.output.insert(item.index, (**item).clone());
}
ModelOutputEvent::Progress(progress) => self.progress.push(progress.clone()),
_ => {}
},
ModelStreamItem::Started => {}
ModelStreamItem::MessageDelta(delta) => {
self.text.push_str(&delta.text);
Expand Down Expand Up @@ -824,6 +841,17 @@ impl StreamAccumulator {
}

if let Some(mut response) = self.completed {
if response.execution.is_none() {
response.execution = self.execution;
}
if let Some(execution) = &mut response.execution
&& execution.progress.is_empty()
{
execution.progress = self.progress;
}
if response.output.is_empty() {
response.output = self.output.into_values().collect();
}
// Reconcile the response and message usage with any streamed
// `UsageDelta`, preferring an already-present value and never
// overwriting a known usage with `None` (which previously clobbered a
Expand Down Expand Up @@ -870,6 +898,13 @@ impl StreamAccumulator {
origin: None,
};
Ok(ModelResponse {
output: self.output.into_values().collect(),
execution: self.execution.map(|mut execution| {

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

Preserve execution progress in reconstructed responses

When a stream has an Execution event whose ModelExecution.progress is already populated but has no separate Progress events, this assignment replaces that progress with the empty accumulator vector. The completed-response path explicitly preserves existing progress and only backfills when it is empty, so the no-Completed path should apply the same rule.


Additional security observation

priority medium confident

Preserve execution progress during reconstruction

[RULE] preserve-stream-metadata

When no Completed item is received, this unconditionally replaces progress already present on execution with self.progress. If progress was supplied in the execution event but no separate Progress events were emitted, the reconstructed response silently loses it. Only backfill progress when the execution's existing progress is empty, matching the handling used for completed responses.

Suggested change for this observation (reference only)

execution: self.execution.map(|mut execution| {
                if execution.progress.is_empty() {
                    execution.progress = self.progress;
                }
                execution
            }),

[RULE] preserve-stream-state ·

if execution.progress.is_empty() {
execution.progress = self.progress;
}
execution
}),
message,
usage: self.usage,
finish_reason: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/tinyinference-llm/src/model/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ fn finish_preserves_provider_error_classification_from_provider_failed() {
let mut acc = StreamAccumulator::new();
acc.push(&ModelStreamItem::Started);
acc.push(&ModelStreamItem::ProviderFailed(ProviderError {
partial_response: None,
provider: "openai".into(),
status: Some(401),
code: Some("invalid_api_key".into()),
Expand Down Expand Up @@ -535,11 +536,13 @@ fn model_stream_item_roundtrips_every_variant() {
// The scalar-carrying variant an internally tagged enum could not encode.
roundtrip_stream_item(ModelStreamItem::Failed("boom".to_string()));
roundtrip_stream_item(ModelStreamItem::ProviderFailed(ProviderError {
partial_response: None,
provider: "openai".into(),
message: "nope".into(),
..ProviderError::default()
}));
roundtrip_stream_item(ModelStreamItem::ProviderFailed(ProviderError {
partial_response: None,
provider: "anthropic".into(),
message: "overloaded".into(),
stop_reason: Some("pause_turn".into()),
Expand Down
Loading
Loading