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
3 changes: 2 additions & 1 deletion crates/tinyinference-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ bytes = { workspace = true }
futures = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
tinyinference-providers = { version = "0.5.0", path = "../tinyinference-providers" }
tinyinference-core = { version = "0.5.0", path = "../tinyinference-core" }
Expand All @@ -43,6 +43,7 @@ tracing = { workspace = true }
axum = { workspace = true }
uuid = { workspace = true }
tower = { version = "0.5", features = ["util"] }
http = "1"
http-body-util = "0.1"
tempfile = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
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 @@ -91,6 +91,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 Down Expand Up @@ -118,6 +119,7 @@ fn per_minute_quota_rate_limits_remain_retryable() {
#[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 @@ -30,12 +30,24 @@ pub use failure::{
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::{
BeforeDispatch, ChatModel, DispatchCheckedModel, FINISH_REASON_LENGTH, InputModality,
InputSource, ModelRequest, ModelResponse, ModelStream, ModelStreamItem,
canonical_finish_reason, 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>,
Comment thread
Johnson-f marked this conversation as resolved.
Resolved
/// 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
2 changes: 2 additions & 0 deletions crates/tinyinference-llm/src/model/error_slot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct ScriptedStreamModel(Mutex<Vec<ModelStreamItem>>);

fn completed() -> ModelResponse {
ModelResponse {
output: Vec::new(),
execution: None,
message: AssistantMessage {
id: None,
content: vec![ContentBlock::Text("ok".to_string())],
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 @@ -20,6 +20,7 @@ pub mod discover;
mod error_slot;
mod fallback;
mod finish_reason;
mod output;
mod response_conversion;
mod types;
pub mod workload;
Expand All @@ -45,6 +46,7 @@ pub use decorators::*;
pub use error_slot::{ErrorSlotModel, ModelErrorSlot};
pub use fallback::FallbackModel;
pub use finish_reason::{FINISH_REASON_LENGTH, canonical_finish_reason};
pub use output::*;
pub use response_conversion::response_to_model_response;
pub use types::*;

Expand Down Expand Up @@ -601,6 +603,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 @@ -697,6 +701,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 @@ -731,6 +738,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 @@ -841,6 +858,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 @@ -887,6 +915,13 @@ impl StreamAccumulator {
origin: None,
};
Ok(ModelResponse {
output: self.output.into_values().collect(),
execution: self.execution.map(|mut execution| {
Comment thread
Johnson-f marked this conversation as resolved.
Resolved
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 @@ -557,6 +557,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 @@ -692,11 +693,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