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

1 change: 1 addition & 0 deletions cell/Cargo.lock

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

8 changes: 5 additions & 3 deletions cell/src/computer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3336,8 +3336,9 @@ async fn egress_codestorage(mut req: Request, env: &Env) -> CellResult<Response>
/// The guest's model call (docs/computers.md, Models): `POST
/// /v1/chat/completions`, OpenAI's shape, with `model` one of the route's
/// names or a model's id, `POST /v1/audio/transcriptions`, OpenAI's
/// multipart shape, with `model` `whisper`, or `POST /v1/decide`, a typed
/// decision on the Clef family; each names its agent
/// multipart shape, with `model` `whisper`, `POST /v1/decide`, a typed
/// decision on the Clef family, or `POST /v1/embeddings`, OpenAI's shape,
/// on the embeddings slot; each names its agent
/// (`models::agent_named`: the `x-fragment-agent` header, or the key
/// `agent:<name>` from a client that sends no header of its own). It is the
/// platform's model route as that agent (`models::route`,
Expand All @@ -3355,8 +3356,9 @@ async fn egress_model(mut req: Request, env: &Env, ctx: &Context, computer: &str
(Method::Post, "/v1/chat/completions") => ("/api/models/v1/chat/completions", crate::models::MODEL_BODY_MAX_BYTES),
(Method::Post, "/v1/audio/transcriptions") => ("/api/models/v1/audio/transcriptions", fragment_core::transcribe::BODY_MAX_BYTES),
(Method::Post, "/v1/decide") => ("/api/models/v1/decide", crate::models::MODEL_BODY_MAX_BYTES),
(Method::Post, "/v1/embeddings") => ("/api/models/v1/embeddings", fragment_core::embed::BODY_MAX_BYTES),
(Method::Get, "/v1/models") => return json_response(&Config::from_env(env).models.listed()),
_ => return Err(CellError::new(ErrorCode::NotFound, "the model intercept answers GET /v1/models, POST /v1/chat/completions, /v1/audio/transcriptions and /v1/decide")),
_ => return Err(CellError::new(ErrorCode::NotFound, "the model intercept answers GET /v1/models, POST /v1/chat/completions, /v1/audio/transcriptions, /v1/decide and /v1/embeddings")),
};
let named = fragment_core::models::agent_named(req.headers().get(AGENT_HEADER)?.as_deref(), req.headers().get("authorization")?.as_deref());
let agent = named.map_err(|why| CellError::new(ErrorCode::Unauthenticated, why.message()))?;
Expand Down
1 change: 1 addition & 0 deletions cell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,7 @@ async fn platform_answer(mut req: Request, env: &Env, ctx: &Context, cfg: &'stat
(Method::Get, ["api", "models", "v1", "models"]) => models::list_route(req, env, &url).await,
(Method::Post, ["api", "models", "v1", "audio", "transcriptions"]) => models::transcription_route(req, env, &url, ctx).await,
(Method::Post, ["api", "models", "v1", "decide"]) => models::decide_route(req, env, &url, ctx).await,
(Method::Post, ["api", "models", "v1", "embeddings"]) => models::embeddings_route(req, env, &url, ctx).await,
(Method::Get, ["api", "identities", id, "picture"]) => picture(env, id).await,
// an operator's wipe of a person (wipe.rs): GET its dry run, POST it
(Method::Get | Method::Post, ["api", "people", person, "wipe"]) => {
Expand Down
93 changes: 84 additions & 9 deletions cell/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ use std::pin::Pin;
use fragment_core::ledger::{MeterRow, Spend};
use fragment_core::models::{self as bounds, Bounded, Model, Named, Stream, Tried, Tries, Upstream};
use fragment_core::price::Usage;
use fragment_core::{decide, multipart, transcribe};
use fragment_core::{decide, embed, multipart, transcribe};
use fragment_proto::{valid_fragment_name, ErrorCode, IdentityKind, Tier};
use futures_util::future::LocalBoxFuture;
use futures_util::StreamExt;
Expand Down Expand Up @@ -499,24 +499,25 @@ struct Metadata {
async fn transport(env: &Env, cfg: &Config, model: Model<'_>, input: &Value, meta: &Metadata) -> CellResult<Response> {
match model.upstream {
Upstream::WorkersAi { model } => workers_ai(env, cfg, model, input, meta).await,
Upstream::Openai { base_url, model: id, body, .. } => openai(env, model.slot, base_url, &bounds::openai_request(input, id, body.as_ref())).await,
Upstream::Openai { base_url, model: id, body, .. } => openai(env, model.slot, base_url, "/chat/completions", &bounds::openai_request(input, id, body.as_ref())).await,
}
}

/// Makes a bounded call on an OpenAI-compatible API: `body` (the input, the
/// slot's own fields and the model's id: `openai_request`), POSTed to
/// `<base_url>/chat/completions` with the key bound for the call's slot
/// (`keys::model_key`) as its bearer token, and nothing of the client's
/// or of the payer's. A key the deployment does not bind is a model not
/// reached (its call falls back), said in the log without it.
async fn openai(env: &Env, slot: &str, base_url: &str, body: &Value) -> CellResult<Response> {
/// slot's own fields and the model's id: `openai_request`; an embeddings
/// call's `embed::request`), POSTed to `<base_url><path>` with the key
/// bound for the call's slot (`keys::model_key`) as its bearer token, and
/// nothing of the client's or of the payer's. A key the deployment does
/// not bind is a model not reached (its call falls back), said in the log
/// without it.
async fn openai(env: &Env, slot: &str, base_url: &str, path: &str, body: &Value) -> CellResult<Response> {
let key = crate::keys::model_key(env, slot).await?;
let h = Headers::new();
h.set("content-type", "application/json")?;
h.set("authorization", &format!("Bearer {key}"))?;
let mut init = RequestInit::new();
init.with_method(Method::Post).with_headers(h).with_body(Some(body.to_string().into()));
let req = Request::new_with_init(&format!("{base_url}/chat/completions"), &init)?;
let req = Request::new_with_init(&format!("{base_url}{path}"), &init)?;
Fetch::Request(req).send().await.map_err(|e| CellError::new(ErrorCode::UpstreamFailed, format!("the model did not answer: {e}")))
}

Expand Down Expand Up @@ -701,6 +702,80 @@ pub(crate) async fn decide_route(mut req: Request, env: &Env, url: &Url, after:
/// What a decision's log lines name it (`Charge::name`).
const DECIDE: &str = "decide";

/// `POST /api/models/v1/embeddings[?fragment=<name>]`: an agent's
/// embeddings (fragment_core::embed), OpenAI's shape, on the deployment's
/// embeddings slot; its caller, payer and fragment `route`'s. Checked
/// before anything is sent (`embed::bound`), charged after its answer at
/// its input tokens, and answered with its vectors' space.
pub(crate) async fn embeddings_route(mut req: Request, env: &Env, url: &Url, after: &dyn Background) -> CellResult<Response> {
let body = read_body(&mut req, embed::BODY_MAX_BYTES).await?;
let caller = caller(env, &req, url, &body).await?;
let asked: Value = serde_json::from_slice(&body).map_err(|e| CellError::invalid(format!("body: {e}")))?;
drop(body);
let slot = &Config::from_env(env).models.embeddings;
let bounded = embed::bound(&asked, slot).map_err(|why| CellError::invalid(why.message()))?;
drop(asked);
match embedded(env, &bounded.inputs, &caller.owner, Some(&caller.agent), caller.in_fragment().as_ref(), after).await? {
Embedding::Vectors { vectors, usage } => Ok(Response::from_json(&embed::reply(slot, bounded.format, &vectors, usage.as_ref()))?),
Embedding::Refused { status, text } => passed(status, text),
}
}

/// What an embeddings call came to: the inputs' vectors (unit length, in
/// the slot's space) and the usage charged for them, or the model's
/// refusal as it came (it used nothing, and nothing is charged).
pub(crate) enum Embedding {
Vectors { vectors: Vec<Vec<f32>>, usage: Option<Usage> },
Refused { status: u16, text: String },
}

/// `inputs` (bounded: `embed::bound`) embedded on the deployment's
/// embeddings slot, metered to `payer` as a model call is (`gate`, then
/// charged after its answer at its input tokens); refused, 403, on a
/// deployment that embeds nothing (`Models::embeddings_refused`: its tiers
/// are private and its slot is not). An answer that is not
/// the slot's (another count, another length) is charged, as it was paid
/// for, and refused 502: a vector of another space is never passed on.
pub(crate) async fn embedded(env: &Env, inputs: &[String], payer: &str, agent: Option<&str>, fragment: Option<&InFragment<'_>>, after: &dyn Background) -> CellResult<Embedding> {
let cfg = Config::from_env(env);
if let Some(why) = cfg.models.embeddings_refused() {
return Err(CellError::new(ErrorCode::Forbidden, why));
}
let slot = &cfg.models.embeddings;
let mut charge = gate(env, payer, agent, fragment, slot.upstream.model(), embed::EMBEDDINGS).await?;
let request = embed::request(slot, inputs);
let meta = Metadata { user_id: opaque(payer), agent_id: agent.map(opaque) };
let answered = match &slot.upstream {
Upstream::WorkersAi { model } => workers_ai(env, cfg, model, &request, &meta).await,
Upstream::Openai { base_url, .. } => openai(env, embed::EMBEDDINGS, base_url, "/embeddings", &request).await,
};
drop(request);
// not reached: it used nothing, and nothing is charged
let mut upstream = answered?;
charge.first_ms = Some(js::now_ms() - charge.t0);
let status = upstream.status_code();
let log_id = upstream.headers().get("cf-aig-log-id")?;
if status != 200 {
return Ok(Embedding::Refused { status, text: bounded_text(&mut upstream).await });
}
let bytes = match read_whole(&mut upstream).await {
Ok(b) => b,
Err(e) => {
// what it used is unknown
charge.later(after, None, log_id);
return Err(e);
}
};
let response: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
drop(bytes);
let usage = embed::usage_of(slot, inputs, &response);
charge.later(after, usage.clone(), log_id);
match embed::answer(slot, inputs.len(), &response) {
Ok(vectors) => Ok(Embedding::Vectors { vectors, usage }),
Err(fault) => Err(CellError::new(ErrorCode::UpstreamFailed, format!("{}: the call is charged", fault.message()))),
}
}

/// `POST /api/models/v1/audio/transcriptions`: an agent's transcription
/// (the module's doc), signed by the agent; OpenAI's multipart shape, its
/// `model` `whisper`. Its owner pays, after its answer. The audio is
Expand Down
3 changes: 3 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ hex = "0.4"
base64 = "0.22"
bech32 = "0.9"
url = "2.5"
# An embeddings input's most tokens (`embed::tokens_max`): its characters'
# decompositions. Already the cell's, through url's idna.
icu_normalizer = { version = "2", default-features = false, features = ["compiled_data"] }
hmac = "0.12"
hkdf = "0.12"
aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"] }
Expand Down
Loading
Loading