Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ca67d6a
test(tinyhumans): cover managed auth failure credential fingerprint
senamakel Oct 24, 2026
f5e0026
feat(tinyinference-llm): fingerprint the credential sent with a request
senamakel Oct 24, 2026
99aa316
test(tinyhumans): rotate token during request in auth failure test
senamakel Oct 24, 2026
37e8fd3
fix(tinyinference-llm): use a real nul byte in the credential fingerp…
senamakel Oct 24, 2026
408fc93
Merge pull request #47 from senamakel/epic-8369-auth-fingerprint
senamakel Oct 24, 2026
80cdc36
test(tinyhumans): cover credential fingerprint hex round trip
senamakel Oct 24, 2026
c9a8687
feat(tinyhumans): add hex encoding for credential fingerprints
senamakel Oct 24, 2026
5d85552
refactor(tinyinference-llm): use as_chunks for hex pair iteration
senamakel Oct 24, 2026
e5b03ae
Merge pull request #48 from senamakel/epic-8369-auth-fingerprint-format
senamakel Oct 24, 2026
bdd4e59
fix(image): resolve bearer credentials asynchronously
senamakel Oct 27, 2026
4d15d85
refactor(embeddings): make bearer resolver async
senamakel Oct 27, 2026
2f1d2d6
refactor(tinyhumans): make bearer resolvers async
senamakel Oct 27, 2026
233c20c
refactor(embeddings): make managed model factory async
senamakel Oct 27, 2026
98426c3
test: adapt factory closures to async signatures
senamakel Oct 27, 2026
956fea1
fix(tinyinference-llm): correct malformed bearer resolver type alias
senamakel Oct 27, 2026
f590fb4
test(embeddings): annotate mock model as trait object in verify test
senamakel Oct 27, 2026
4cdf479
test(tinyinference-llm): clone captured state into async resolver blocks
senamakel Oct 27, 2026
3db292b
refactor(providers): make embedding and catalog limit factories async
senamakel Oct 27, 2026
a8469e2
refactor(providers): extract named future aliases for override factories
senamakel Oct 27, 2026
32ca8b5
refactor(providers): pass request structs by value to factories
senamakel Oct 27, 2026
ce35612
test(provider-overrides): update tests for request struct overrides
senamakel Oct 27, 2026
3a106e5
test(provider_overrides): reformat test file with rustfmt
senamakel Oct 27, 2026
e73e79c
test(provider_overrides): simplify embedding factory closures
senamakel Oct 27, 2026
825aa53
refactor(providers): own embedding and catalog request data
senamakel Oct 27, 2026
8b70332
test(providers): cover owned non-clone host contexts in overrides
senamakel Oct 27, 2026
3bb0f2d
test(provider_overrides): annotate async factory closures with future…
senamakel Oct 27, 2026
25f23ec
test(provider-overrides): use distinct host contexts in override tests
senamakel Oct 27, 2026
15d0ec4
Merge pull request #49 from senamakel/fix/async-image-bearer-resolver
senamakel Oct 27, 2026
e283e7f
Merge senamakel/tinyinference epic/8369-security-migration into main
senamakel Nov 3, 2026
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.

13 changes: 8 additions & 5 deletions crates/tinyinference-embeddings/src/cloud.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Bearer-authenticated OpenAI-compatible cloud embedding model.

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use async_trait::async_trait;
Expand All @@ -13,7 +15,8 @@ pub const DEFAULT_CLOUD_MODEL: &str = "embedding-v1";
pub const DEFAULT_CLOUD_DIMENSIONS: usize = 1024;

/// Resolves the current bearer token for each request.
pub type BearerResolver = Arc<dyn Fn() -> Result<String> + Send + Sync>;
pub type BearerResolver =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<String>> + Send>> + Send + Sync>;

/// Host policy hook invoked immediately before an embedding batch may leave
/// the process. This keeps privacy and audit policy in the host without
Expand Down Expand Up @@ -81,7 +84,7 @@ impl CloudEmbeddingModel {
///
/// [`Error::Validation`] when an input is empty or whitespace, when the
/// bearer resolver fails, or when it resolves to a blank token.
fn delegate(&self, texts: &[String]) -> Result<Option<OpenAiEmbeddingModel>> {
async fn delegate(&self, texts: &[String]) -> Result<Option<OpenAiEmbeddingModel>> {
if texts.is_empty() {
return Ok(None);
}
Expand All @@ -95,7 +98,7 @@ impl CloudEmbeddingModel {
if let Some(guard) = &self.egress_guard {
guard(&self.model, texts.len())?;
}
let bearer = (self.bearer)()?;
let bearer = (self.bearer)().await?;
if bearer.trim().is_empty() {
return Err(Error::Validation(
"No backend session for cloud embeddings".into(),
Expand Down Expand Up @@ -128,7 +131,7 @@ impl EmbeddingModel for CloudEmbeddingModel {
}

async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
let Some(delegate) = self.delegate(texts)? else {
let Some(delegate) = self.delegate(texts).await? else {
return Ok(Vec::new());
};
delegate.embed(texts).await
Expand All @@ -140,7 +143,7 @@ impl EmbeddingModel for CloudEmbeddingModel {
&self,
texts: &[String],
) -> Result<(Vec<Vec<f32>>, Option<EmbeddingUsage>)> {
let Some(delegate) = self.delegate(texts)? else {
let Some(delegate) = self.delegate(texts).await? else {
return Ok((Vec::new(), None));
};
delegate.embed_with_usage(texts).await
Expand Down
10 changes: 6 additions & 4 deletions crates/tinyinference-embeddings/src/cloud_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ use super::*;

fn missing_bearer() -> BearerResolver {
Arc::new(|| {
Err(Error::Validation(
"No backend session for cloud embeddings".into(),
))
Box::pin(async {
Err(Error::Validation(
"No backend session for cloud embeddings".into(),
))
})
})
}

Expand Down Expand Up @@ -49,7 +51,7 @@ async fn empty_and_blank_batches_skip_egress_guard_and_bearer_for_both_entrypoin
8,
Arc::new(move || {
bearer_count.fetch_add(1, Ordering::SeqCst);
Ok("token".into())
Box::pin(async { Ok("token".into()) })
}),
)
.with_egress_guard(Arc::new(move |_, _| {
Expand Down
23 changes: 14 additions & 9 deletions crates/tinyinference-embeddings/src/factory.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Provider construction and custom-endpoint validation.

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use crate::{
Expand All @@ -12,8 +14,11 @@ use crate::{
pub type CredentialResolver = Arc<dyn Fn(&str) -> String + Send + Sync>;

/// Constructs the host-authenticated managed embedding model.
pub type ManagedModelFactory =
Arc<dyn Fn(&str, usize) -> Result<Box<dyn EmbeddingModel>> + Send + Sync>;
pub type ManagedModelFactory = Arc<
dyn Fn(&str, usize) -> Pin<Box<dyn Future<Output = Result<Box<dyn EmbeddingModel>>> + Send>>
+ Send
+ Sync,
>;

/// Host-independent inputs for selecting an embedding provider.
#[derive(Clone, Debug)]
Expand All @@ -30,7 +35,7 @@ pub struct EmbeddingFactorySettings {

/// Builds a provider from persisted settings while keeping credential storage
/// and managed authentication in host-supplied callbacks.
pub fn create_configured_embedding_model(
pub async fn create_configured_embedding_model(
settings: &EmbeddingFactorySettings,
credentials: &CredentialResolver,
managed: &ManagedModelFactory,
Expand All @@ -42,7 +47,7 @@ pub fn create_configured_embedding_model(
None => (provider, None),
};
if matches!(slug, "cloud" | "managed") {
return managed(&settings.model, settings.dimensions);
return managed(&settings.model, settings.dimensions).await;
}
let api_key = credentials(slug);
create_embedding_model(
Expand All @@ -57,14 +62,14 @@ pub fn create_configured_embedding_model(

/// Builds the configured provider, falling back to the managed model when the
/// selection is blank, invalid, or missing a required credential.
pub fn create_default_embedding_model(
pub async fn create_default_embedding_model(
settings: &EmbeddingFactorySettings,
credentials: &CredentialResolver,
managed: &ManagedModelFactory,
) -> Result<Box<dyn EmbeddingModel>> {
let provider = settings.provider.trim();
if provider.is_empty() || matches!(provider, "cloud" | "managed") {
return managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS);
return managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS).await;
}

let slug = if provider.starts_with("custom:") {
Expand All @@ -79,14 +84,14 @@ pub fn create_default_embedding_model(
provider = slug,
"configured embedding provider has no credential; falling back to managed"
);
return managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS);
return managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS).await;
}

match create_configured_embedding_model(settings, credentials, managed) {
match create_configured_embedding_model(settings, credentials, managed).await {
Ok(model) => Ok(model),
Err(error) => {
tracing::warn!(provider = slug, %error, "configured embedding provider failed to build; falling back to managed");
managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS)
managed(DEFAULT_CLOUD_MODEL, DEFAULT_CLOUD_DIMENSIONS).await
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/tinyinference-embeddings/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ pub async fn test_embedding_connection_detailed(
});
}
let model = create_configured_embedding_model(settings, credentials, managed)
.await
.map_err(ConnectionTestError::Build)?;
let vectors = model
.embed(&["connection test".to_owned()])
Expand Down
19 changes: 13 additions & 6 deletions crates/tinyinference-embeddings/src/verify_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ async fn connection_test_uses_host_managed_factory_and_returns_actual_width() {
ollama_base_url: "http://localhost:11434".into(),
};
let credentials: CredentialResolver = std::sync::Arc::new(|_| String::new());
let managed: ManagedModelFactory =
std::sync::Arc::new(|_, dims| Ok(Box::new(crate::MockEmbeddingModel::new(dims))));
let managed: ManagedModelFactory = std::sync::Arc::new(|_, dims| {
Box::pin(async move {
Ok(Box::new(crate::MockEmbeddingModel::new(dims)) as Box<dyn crate::EmbeddingModel>)
})
});
let result = test_embedding_connection(&settings, &credentials, &managed, None)
.await
.unwrap();
Expand All @@ -139,7 +142,8 @@ async fn detailed_connection_test_identifies_model_build_failures() {
ollama_base_url: "http://localhost:11434".into(),
};
let credentials: CredentialResolver = std::sync::Arc::new(|_| String::new());
let managed: ManagedModelFactory = std::sync::Arc::new(|_, _| unreachable!());
let managed: ManagedModelFactory =
std::sync::Arc::new(|_, _| Box::pin(async { unreachable!() }));

assert!(matches!(
test_embedding_connection_detailed(&settings, &credentials, &managed, None).await,
Expand Down Expand Up @@ -177,7 +181,9 @@ async fn detailed_connection_test_identifies_embedding_failures() {
ollama_base_url: "http://localhost:11434".into(),
};
let credentials: CredentialResolver = std::sync::Arc::new(|_| String::new());
let managed: ManagedModelFactory = std::sync::Arc::new(|_, _| Ok(Box::new(FailingModel)));
let managed: ManagedModelFactory = std::sync::Arc::new(|_, _| {
Box::pin(async { Ok(Box::new(FailingModel) as Box<dyn crate::EmbeddingModel>) })
});

assert!(matches!(
test_embedding_connection_detailed(&settings, &credentials, &managed, None).await,
Expand All @@ -194,7 +200,8 @@ async fn connection_test_preserves_noop_provider_success() {
ollama_base_url: String::new(),
};
let credentials: CredentialResolver = std::sync::Arc::new(|_| String::new());
let managed: ManagedModelFactory = std::sync::Arc::new(|_, _| unreachable!());
let managed: ManagedModelFactory =
std::sync::Arc::new(|_, _| Box::pin(async { unreachable!() }));
let result = test_embedding_connection(&settings, &credentials, &managed, None)
.await
.unwrap();
Expand Down Expand Up @@ -331,7 +338,7 @@ async fn custom_connection_infers_stored_endpoint_and_resolves_custom_credential
capture.lock().unwrap().push(slug.to_owned());
"key".into()
});
let managed: ManagedModelFactory = Arc::new(|_, _| unreachable!());
let managed: ManagedModelFactory = Arc::new(|_, _| Box::pin(async { unreachable!() }));
let result = test_embedding_connection(&settings, &credentials, &managed, None)
.await
.unwrap();
Expand Down
21 changes: 16 additions & 5 deletions crates/tinyinference-image/src/openrouter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,26 @@ fn enveloped_png_reply(_call: usize) -> Response {
#[tokio::test]
async fn proxied_backend_base_url_and_bearer_resolver() {
let fixture = fixture("/agent-integrations/openrouter", enveloped_png_reply, None).await;
let resolver: crate::BearerResolver = Arc::new(|| Ok("session-jwt-abcdefgh".to_owned()));
let resolver_calls = Arc::new(AtomicUsize::new(0));
let resolver: crate::BearerResolver = {
let resolver_calls = Arc::clone(&resolver_calls);
Arc::new(move || {
let call = resolver_calls.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Ok(format!("session-jwt-{call}")) })
})
};
let generator = OpenRouterImageGenerator::with_transport(
MediaTransport::new(MediaAuth::Bearer(resolver)).with_base_url(&fixture.base_url),
);
)
.with_capability_check(false);
let response = generator.generate(ImageRequest::new("x")).await.unwrap();
assert_eq!(response.images.len(), 1);
assert_eq!(response.cost_usd, Some(0.035));
let headers = fixture.captured.headers.lock().unwrap()[0].clone();
assert_eq!(headers["authorization"], "Bearer session-jwt-abcdefgh");
generator.generate(ImageRequest::new("y")).await.unwrap();
assert_eq!(resolver_calls.load(Ordering::SeqCst), 2);
let headers = fixture.captured.headers.lock().unwrap();
assert_eq!(headers[0]["authorization"], "Bearer session-jwt-0");
assert_eq!(headers[1]["authorization"], "Bearer session-jwt-1");
}

#[tokio::test]
Expand All @@ -288,7 +299,7 @@ async fn failed_envelope_is_an_error_even_with_a_2xx_status() {
#[tokio::test]
async fn blank_bearer_fails_without_a_request() {
let fixture = fixture("/api/v1", png_reply, None).await;
let resolver: crate::BearerResolver = Arc::new(|| Ok(" ".to_owned()));
let resolver: crate::BearerResolver = Arc::new(|| Box::pin(async { Ok(" ".to_owned()) }));
let generator = OpenRouterImageGenerator::with_transport(
MediaTransport::new(MediaAuth::Bearer(resolver)).with_base_url(&fixture.base_url),
)
Expand Down
22 changes: 12 additions & 10 deletions crates/tinyinference-image/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
//! connection after a submit is surfaced as-is, because resubmitting could
//! bill a second generation.

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -40,7 +42,8 @@ pub const DEFAULT_MAX_MEDIA_BYTES: usize = 512 * 1024 * 1024;
const MAX_ERROR_BODY_BYTES: usize = 16 * 1024;

/// Resolves the current bearer token for each request.
pub type BearerResolver = Arc<dyn Fn() -> Result<String> + Send + Sync>;
pub type BearerResolver =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<String>> + Send>> + Send + Sync>;

/// How requests authenticate.
#[derive(Clone)]
Expand All @@ -64,10 +67,10 @@ impl MediaAuth {
}
}

fn token(&self) -> Result<String> {
async fn token(&self) -> Result<String> {
let token = match self {
Self::ApiKey(key) => key.clone(),
Self::Bearer(resolve) => resolve()?,
Self::Bearer(resolve) => resolve().await?,
};
let token = token.trim().to_owned();
if token.is_empty() {
Expand Down Expand Up @@ -197,7 +200,7 @@ impl MediaTransport {
T: DeserializeOwned,
{
let body = serde_json::to_vec(body)?;
let response = self
let (response, _) = self
.send(reqwest::Method::POST, path, Some(body), Billing::Billable)
.await?;
decode_json_with_limit(response, self.json_limit()).await
Expand All @@ -210,7 +213,7 @@ impl MediaTransport {
/// [`Error::Auth`], [`Error::Http`], [`Error::Transport`] or
/// [`Error::Decode`] after retries are exhausted.
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self
let (response, _) = self
.send(reqwest::Method::GET, path, None, Billing::Idempotent)
.await?;
decode_json_with_limit(response, self.json_limit()).await
Expand Down Expand Up @@ -264,8 +267,7 @@ impl MediaTransport {
}

async fn get_bytes_once(&self, path: &str) -> Result<(Bytes, Option<String>)> {
let token = self.auth.token()?;
let mut response = self
let (mut response, token) = self
.send(reqwest::Method::GET, path, None, Billing::Idempotent)
.await?;
let content_type = response
Expand Down Expand Up @@ -317,11 +319,11 @@ impl MediaTransport {
path: &str,
body: Option<Vec<u8>>,
billing: Billing,
) -> Result<reqwest::Response> {
) -> Result<(reqwest::Response, String)> {
let url = self.url(path);
let mut attempt = 0u32;
loop {
let token = self.auth.token()?;
let token = self.auth.token().await?;
let mut request = self
.client
.request(method.clone(), &url)
Expand All @@ -340,7 +342,7 @@ impl MediaTransport {
);
let outcome = request.send().await;
let (retry, retry_after, error) = match outcome {
Ok(response) if response.status().is_success() => return Ok(response),
Ok(response) if response.status().is_success() => return Ok((response, token)),
Ok(response) => {
let status = response.status().as_u16();
let retry_after = response
Expand Down
1 change: 1 addition & 0 deletions crates/tinyinference-llm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ futures = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2.workspace = true
thiserror = { workspace = true }
tinyinference-providers = { version = "0.5.0", path = "../tinyinference-providers" }
tinyinference-core = { version = "0.5.0", path = "../tinyinference-core" }
Expand Down
Loading
Loading