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
13 changes: 13 additions & 0 deletions Cargo.lock

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

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
thiserror = "2"
tracing = "0.1"

# Async runtime primitives used by the streaming pipeline, plus task spawning
# for `DurabilityMode::Async` background checkpoint writes, `spawn_blocking`
Expand Down Expand Up @@ -53,7 +54,7 @@ rusqlite = { version = "0.40", features = ["bundled"], optional = true }
rhai = { version = "1", features = ["sync"], optional = true }

# Optional builtin tool family for deterministic time/date helpers.
chrono = { version = "0.4", optional = true }
chrono = "0.4"
chrono-tz = { version = "0.10", optional = true }

[features]
Expand All @@ -70,7 +71,7 @@ repl = ["dep:rhai"]
rlm = ["dep:rhai", "tokio/process", "tokio/io-util"]
# Builtin generic tools (`harness::tools`) kept out of the default dependency
# graph so host applications can choose whether they want these implementations.
tools = ["dep:chrono", "dep:chrono-tz"]
tools = ["dep:chrono-tz"]

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] }
Expand Down
35 changes: 21 additions & 14 deletions examples/local_model_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ fn first_call(response: &ModelResponse) -> Option<&ToolCall> {
response.message.tool_calls.first()
}


fn msg_text(message: &AssistantMessage) -> String {
message
.content
Expand Down Expand Up @@ -123,7 +122,10 @@ async fn tool_call(model: &OpenAiModel) -> Outcome {
format!(
"no tool_calls; finish={:?} text={:?}",
resp.finish_reason,
msg_text(&resp.message).chars().take(120).collect::<String>()
msg_text(&resp.message)
.chars()
.take(120)
.collect::<String>()
),
),
},
Expand Down Expand Up @@ -240,18 +242,18 @@ async fn parallel_tools(model: &OpenAiModel) -> Outcome {
.iter()
.map(|c| c.id.as_str())
.collect();
let unique = ids
.iter()
.collect::<std::collections::HashSet<_>>()
.len();
let unique = ids.iter().collect::<std::collections::HashSet<_>>().len();
if n >= 2 && unique == n {
ok("parallel-tools", format!("{n} calls, ids={ids:?}"))
} else if n >= 2 {
fail("parallel-tools", format!("duplicate ids: {ids:?}"))
} else {
// Small models often serialize calls across turns; only flag
// this as informational, not a harness bug.
ok("parallel-tools", format!("model made {n} call(s) (model behavior)"))
ok(
"parallel-tools",
format!("model made {n} call(s) (model behavior)"),
)
}
}
Err(e) => fail("parallel-tools", e.to_string()),
Expand Down Expand Up @@ -313,16 +315,16 @@ async fn streaming_tools(model: &OpenAiModel) -> Outcome {
"streaming-tools",
format!("id={:?} args={}", call.id, call.arguments),
),
Some(call) => fail(
"streaming-tools",
format!("bad args: {}", call.arguments),
),
Some(call) => fail("streaming-tools", format!("bad args: {}", call.arguments)),
None => fail(
"streaming-tools",
format!(
"no tool call; finish={:?} text={:?}",
resp.finish_reason,
msg_text(&resp.message).chars().take(120).collect::<String>()
msg_text(&resp.message)
.chars()
.take(120)
.collect::<String>()
),
),
},
Expand All @@ -345,7 +347,9 @@ async fn json_object(model: &OpenAiModel) -> Outcome {
Ok(resp) => {
let text = msg_text(&resp.message);
match serde_json::from_str::<serde_json::Value>(text.trim()) {
Ok(v) if v.is_object() => ok("json-object", text.chars().take(80).collect::<String>()),
Ok(v) if v.is_object() => {
ok("json-object", text.chars().take(80).collect::<String>())
}
_ => fail("json-object", format!("not a JSON object: {text:?}")),
}
}
Expand Down Expand Up @@ -395,7 +399,10 @@ async fn thinking_leak(model: &OpenAiModel) -> Outcome {
if text.contains("<think>") || text.contains("</think>") {
fail(
"thinking-leak",
format!("<think> leaked into text: {:?}", text.chars().take(120).collect::<String>()),
format!(
"<think> leaked into text: {:?}",
text.chars().take(120).collect::<String>()
),
)
} else {
ok("thinking-leak", text.chars().take(60).collect::<String>())
Expand Down
124 changes: 124 additions & 0 deletions src/harness/embeddings/cloud.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Bearer-authenticated OpenAI-compatible cloud embedding model.

use std::sync::Arc;

use async_trait::async_trait;

use super::{EmbeddingModel, OpenAiEmbeddingModel};
use crate::error::{Result, TinyAgentsError};

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>;

/// Cloud model whose credential lifecycle remains owned by the host.
pub struct CloudEmbeddingModel {
client: reqwest::Client,
base_url: String,
model: String,
dimensions: usize,
bearer: BearerResolver,
}

impl CloudEmbeddingModel {
pub fn new(
base_url: impl Into<String>,
model: impl Into<String>,
dimensions: usize,
bearer: BearerResolver,
) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.into().trim().trim_end_matches('/').to_owned(),
model: model.into(),
dimensions,
bearer,
}
}
}

#[async_trait]
impl EmbeddingModel for CloudEmbeddingModel {
fn name(&self) -> &str {
"cloud"
}

fn model_id(&self) -> &str {
&self.model
}

fn dimensions(&self) -> usize {
self.dimensions
}

async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
if let Some(index) = texts.iter().position(|text| text.trim().is_empty()) {
return Err(TinyAgentsError::Validation(format!(
"cloud embed: refusing empty/whitespace input at index {index} of {} (model={})",
texts.len(),
self.model
)));
}
let bearer = (self.bearer)()?;
if bearer.trim().is_empty() {
return Err(TinyAgentsError::Validation(
"No backend session for cloud embeddings".into(),
));
}
OpenAiEmbeddingModel::new(bearer)
.with_client(self.client.clone())
.with_base_url(&self.base_url)
.with_model(&self.model)
.with_dimensions(self.dimensions)
.with_send_dimensions(false)
.with_required_api_key(true)
.embed(texts)
.await
}
}

#[cfg(test)]
mod tests {
use super::*;

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

#[test]
fn identity_matches_host_contract() {
let model = CloudEmbeddingModel::new(
"https://api.example/openai/v1/",
DEFAULT_CLOUD_MODEL,
DEFAULT_CLOUD_DIMENSIONS,
missing_bearer(),
);
assert_eq!(model.name(), "cloud");
assert_eq!(
model.signature(),
"provider=cloud;model=embedding-v1;dims=1024"
);
}

#[tokio::test]
async fn validation_precedes_bearer_resolution() {
let model = CloudEmbeddingModel::new(
"https://api.example/openai/v1",
DEFAULT_CLOUD_MODEL,
DEFAULT_CLOUD_DIMENSIONS,
missing_bearer(),
);
assert!(model.embed(&[]).await.unwrap().is_empty());
let error = model.embed(&[" ".into()]).await.unwrap_err();
assert!(error.to_string().contains("empty/whitespace"));
}
}
Loading