use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use tracing::{Instrument, field, info_span};
use crate::api::{Client, ModelInfo, QuestionId, Request};
use crate::ask::{Ask, Plan, Reply};
use crate::policy::{self, Outcome, Thresholds, Verdict};
use crate::{ApiKey, Error, Model, Policy, State};
const TARGET: &str = "guideme";
#[derive(Clone, Debug)]
pub struct Guide {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
client: Client,
model: Model,
policy: Policy,
record_state: bool,
}
#[derive(Debug)]
pub struct GuideBuilder {
api_key: Option<ApiKey>,
base_url: Option<String>,
model: Model,
policy: Policy,
max_retries: u32,
timeout: Duration,
record_state: bool,
}
impl Guide {
pub fn from_env() -> Result<Self, Error> {
let key = std::env::var("TYPESAFE_API_KEY").map_err(|_| Error::Config {
detail: "TYPESAFE_API_KEY is not set".into(),
})?;
let mut b = Self::builder().api_key(ApiKey::from(key));
if let Ok(url) = std::env::var("TYPESAFE_BASE_URL") {
b = b.base_url(url);
}
if let Ok(model) = std::env::var("GUIDEME_MODEL") {
b = b.model(Model::new(model));
}
b.build()
}
pub fn builder() -> GuideBuilder {
GuideBuilder {
api_key: None,
base_url: None,
model: Model::latest(),
policy: Policy::new(),
max_retries: 3,
timeout: Duration::from_secs(30),
record_state: false,
}
}
pub fn with_policy(&self, policy: Policy) -> Result<Guide, Error> {
let policy = policy.over(self.inner.policy);
policy.settle()?;
Ok(Guide {
inner: Arc::new(Inner {
client: self.inner.client.clone(),
model: self.inner.model.clone(),
policy,
record_state: self.inner.record_state,
}),
})
}
pub async fn ask<A: Ask>(&self, ask: A, state: impl Into<State>) -> Result<A::Out, Error> {
let state: State = state.into();
let state_json = serde_json::to_string(state.value()?).map_err(|e| Error::Config {
detail: e.to_string(),
})?;
let mut plan = Plan::new(self.inner.policy);
let claim = ask.encode(&mut plan)?;
if plan.questions.is_empty() {
return Err(Error::Config {
detail: "a batch needs at least one question".into(),
});
}
let (host, port) = self.inner.client.server();
let span = info_span!(
target: TARGET,
"guideme.ask",
otel.kind = "client",
gen_ai.provider.name = "typesafe",
gen_ai.operation.name = "ask",
gen_ai.request.model = self.inner.model.as_str(),
gen_ai.response.model = field::Empty,
gen_ai.usage.input_tokens = field::Empty,
gen_ai.usage.output_tokens = field::Empty,
server.address = host,
server.port = i64::from(port),
guideme.questions = signed(plan.questions.len()),
guideme.state.bytes = signed(state_json.len()),
guideme.state = field::Empty,
error.type = field::Empty,
otel.status_code = field::Empty,
otel.status_description = field::Empty,
);
if self.inner.record_state {
span.record("guideme.state", state_json.as_str());
}
let request = Request {
state,
model: self.inner.model.clone(),
questions: plan.questions,
};
let result = self
.inner
.client
.evaluate(&request)
.instrument(span.clone())
.await;
let response = match result {
Ok(response) => response,
Err(e) => {
fail(&span, &e);
return Err(e);
}
};
span.record("gen_ai.response.model", response.model.as_str());
span.record(
"gen_ai.usage.input_tokens",
signed(response.usage.input_tokens),
);
span.record(
"gen_ai.usage.output_tokens",
signed(response.usage.output_tokens),
);
let decoded = span.in_scope(|| {
let mut outcomes = BTreeMap::new();
for (id, t) in plan.thresholds {
let answer = response.answers.get(&id).ok_or_else(|| Error::Protocol {
detail: format!("no answer for question {}", id.as_str()),
})?;
let outcome = policy::resolve(answer, t)?;
emit(&id, &outcome, t);
outcomes.insert(id, (outcome, t));
}
A::decode(claim, &Reply { outcomes })
});
if let Err(e) = &decoded {
fail(&span, e);
}
decoded
}
pub async fn models(&self) -> Result<Vec<ModelInfo>, Error> {
self.inner.client.models().await
}
}
fn fail(span: &tracing::Span, error: &Error) {
span.record("error.type", error.kind());
span.record("otel.status_code", "ERROR");
span.record("otel.status_description", describe(error).as_str());
}
fn describe(error: &Error) -> String {
match error {
Error::Invalid { .. } => "invalid request: the 422 body is on the returned error".into(),
Error::UnexpectedStatus { status, .. } => {
format!("unexpected status {status}: the body is on the returned error")
}
Error::Auth
| Error::RateLimited { .. }
| Error::Overloaded
| Error::Transport(_)
| Error::Protocol { .. }
| Error::Unsure { .. }
| Error::Config { .. } => error.to_string(),
}
}
fn signed(n: impl TryInto<i64>) -> i64 {
n.try_into().unwrap_or(i64::MAX)
}
fn emit(id: &QuestionId, outcome: &Outcome, t: Thresholds) {
match outcome {
Outcome::Noul(v) => {
let (label, p) = match v {
Verdict::Yes(p) => ("yes", p),
Verdict::No(p) => ("no", p),
Verdict::Unsure(p) => ("unsure", p),
};
tracing::event!(
name: "guideme.answer",
target: TARGET,
tracing::Level::INFO,
guideme.question = id.as_str(),
guideme.kind = "noul",
guideme.outcome = label,
guideme.probability = p.get(),
guideme.unsure = matches!(v, Verdict::Unsure(_)),
guideme.yes_above = t.yes_above(),
guideme.no_below = t.no_below(),
guideme.min_confidence = t.min_confidence(),
"{} noul: {label}",
id.as_str(),
);
}
Outcome::Choice {
key,
confidence,
unsure,
..
} => {
tracing::event!(
name: "guideme.answer",
target: TARGET,
tracing::Level::INFO,
guideme.question = id.as_str(),
guideme.kind = "choice",
guideme.outcome = key.as_str(),
guideme.confidence = confidence.get(),
guideme.unsure = *unsure,
guideme.yes_above = t.yes_above(),
guideme.no_below = t.no_below(),
guideme.min_confidence = t.min_confidence(),
"{} choice: {}",
id.as_str(),
key.as_str(),
);
}
Outcome::Score {
index,
value,
confidence,
unsure,
..
} => {
tracing::event!(
name: "guideme.answer",
target: TARGET,
tracing::Level::INFO,
guideme.question = id.as_str(),
guideme.kind = "score",
guideme.outcome = signed(*index),
guideme.value = *value,
guideme.confidence = confidence.get(),
guideme.unsure = *unsure,
guideme.yes_above = t.yes_above(),
guideme.no_below = t.no_below(),
guideme.min_confidence = t.min_confidence(),
"{} score: level {index}",
id.as_str(),
);
}
}
}
impl GuideBuilder {
pub fn api_key(mut self, key: ApiKey) -> Self {
self.api_key = Some(key);
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn model(mut self, model: Model) -> Self {
self.model = model;
self
}
pub fn policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = n;
self
}
pub fn timeout(mut self, d: Duration) -> Self {
self.timeout = d;
self
}
pub fn record_state(mut self, on: bool) -> Self {
self.record_state = on;
self
}
pub fn build(self) -> Result<Guide, Error> {
let key = self.api_key.ok_or_else(|| Error::Config {
detail: "api_key is required".into(),
})?;
self.policy.settle()?;
let mut client = Client::builder(key)
.max_retries(self.max_retries)
.timeout(self.timeout);
if let Some(url) = self.base_url {
client = client.base_url(url);
}
Ok(Guide {
inner: Arc::new(Inner {
client: client.build()?,
model: self.model,
policy: self.policy,
record_state: self.record_state,
}),
})
}
}