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
32 changes: 30 additions & 2 deletions apps/maple-agent/crates/maple-agent/src/agent/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,17 @@ const MAPLE_BASE_URL: &str = "/v1";
const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";
/// Where Pi's System One classifier posts: `{base_url}/systemone`.
const SYSTEM_ONE_PATH: &str = "/v1/systemone";
/// The model System One answers with, the only one Maple's API serves.
/// The model System One answers with by default.
pub(crate) const SYSTEM_ONE_MODEL_ID: &str = "glm-5-3-flash";
/// The decision models Maple's System One API also serves (Tinfoil's), with
/// their names: text only, at most 26 options per choice, and a 32,768-token
/// context per question.
const SYSTEM_ONE_DECISION_MODELS: [(&str, &str); 3] = [
("decision-small", "Decision Small"),
("decision-medium", "Decision Medium"),
("decision-large", "Decision Large"),
];
const DECISION_MODEL_CONTEXT_WINDOW: u64 = 32_768;

pub(crate) const AUTHENTICATION_ERROR_MESSAGE: &str = "Maple authentication failed";
pub(crate) const CREDITS_EXHAUSTED_MESSAGE: &str = "Maple credits are exhausted";
Expand Down Expand Up @@ -144,7 +153,9 @@ pub(crate) fn maple_model_registry(
system_one::API,
Arc::new(SystemOne::new(Arc::new(MapleTransport { transport }))),
);
registry.register_classifier_models([system_one_model()]);
registry.register_classifier_models(
std::iter::once(system_one_model()).chain(system_one_decision_models()),
);
registry
}

Expand All @@ -165,6 +176,23 @@ pub(crate) fn system_one_model() -> ClassifierModel {
}
}

/// The decision models as Pi classifier models: they judge text only.
fn system_one_decision_models() -> impl Iterator<Item = ClassifierModel> {
SYSTEM_ONE_DECISION_MODELS
.into_iter()
.map(|(id, name)| ClassifierModel {
id: id.to_string(),
name: name.to_string(),
api: system_one::API.to_string(),
provider: MAPLE_PROVIDER.to_string(),
base_url: MAPLE_BASE_URL.to_string(),
input: vec![InputModality::Text],
cost: Default::default(),
context_window: DECISION_MODEL_CONTEXT_WINDOW,
headers: Default::default(),
})
}

/// OpenSecret authenticates the account itself; Pi passes no key.
struct NoKeys;

Expand Down
24 changes: 24 additions & 0 deletions apps/maple-agent/crates/maple-agent/src/agent/provider/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,30 @@ async fn system_one_classifies_over_the_accounts_transport() {
assert_eq!(requests[1].body["questions"]["urgent"]["type"], "noul");
}

#[tokio::test]
async fn decision_models_classify_text_through_the_same_api() {
let transport = FakeTransport::new(vec![json_reply(200, Vec::new(), SYSTEM_ONE_REPLY)]);
let registry = maple_model_registry(transport.clone(), []);
for id in ["decision-small", "decision-medium", "decision-large"] {
let model = registry
.find_classifier(MAPLE_PROVIDER, id)
.expect("decision model is registered");
assert!(!model.supports_images(), "{id}");
assert_eq!(model.context_window, 32_768, "{id}");
}
let model = registry
.find_classifier(MAPLE_PROVIDER, "decision-medium")
.unwrap();
let result = registry
.classify(&model, urgent_ticket(), pi_ai::ClassifierOptions::default())
.await;
assert!(!result.is_failure(), "{result:?}");
let requests = transport.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].uri, SYSTEM_ONE_PATH);
assert_eq!(requests[0].body["model"], "decision-medium");
}

#[tokio::test]
async fn a_failed_system_one_request_keeps_its_body_out_of_the_error() {
let transport = FakeTransport::new(vec![json_reply(
Expand Down
11 changes: 7 additions & 4 deletions proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,13 @@ curl http://localhost:8080/v1/systemone \
}'
```

Typed questions (`noul`, `choice`, `score`) answered with calibrated
probabilities on Continuum GLM-5.3-Flash; request and response follow the
TypeSafe System One schema, with optional `images` (`data:` URLs) and
`temperature` fields. Rejections keep the backend's status and
Typed questions (`noul`, `choice`, `score`) answered with probabilities
instead of text; request and response follow the TypeSafe System One schema,
with optional `images` (`data:` URLs) and `temperature` fields. `model` picks
the engine: `glm-5-3-flash` (the default, on Continuum, calibrated, up to 255
options per choice) or Tinfoil's `decision-small`, `decision-medium` and
`decision-large` (text only, up to 26 options per choice, one upstream request
per call). Rejections keep the backend's status and
`x-opensecret-error-code` header, for example `422` with
`system_one_too_many_questions`, or `system_one_unknown_field` naming a
misspelled key.
Expand Down
8 changes: 5 additions & 3 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,11 @@ different callback. Native Apple sign-in and the Rust SDK are unchanged.
### System One decisions

`systemOne` answers typed questions (`noul`, `choice`, `score`) about a JSON
state with calibrated probabilities instead of text (`POST /v1/systemone`,
Continuum GLM-5.3-Flash). It uses the signed-in session, or an API key through
`options.apiKey`; `useOpenSecret().systemOne` is the same function:
state with probabilities instead of text (`POST /v1/systemone`). `model` is
`glm-5-3-flash` by default (Continuum, calibrated, takes `images`) or one of
Tinfoil's `decision-small`, `decision-medium` and `decision-large` (text only,
up to 26 options per choice). It uses the signed-in session, or an API key
through `options.apiKey`; `useOpenSecret().systemOne` is the same function:

```ts
import { systemOne, SystemOneError } from "@mapleai/sdk";
Expand Down
16 changes: 10 additions & 6 deletions sdk/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,13 @@ logical request and is not installed as mutable client state.

### System One decisions

`system_one` answers typed questions about a JSON state with one masked
single-token decision each, returning calibrated probabilities instead of text
(`POST /v1/systemone`, Continuum GLM-5.3-Flash). Questions and choice options
keep the order you give them:
`system_one` answers typed questions about a JSON state with probabilities
instead of text (`POST /v1/systemone`). The default model, `glm-5-3-flash`,
reads one masked single-token decision per question on Continuum and
calibrates it; `with_model("decision-medium")` (or `decision-small`,
`decision-large`) uses Tinfoil's decision models, which read text only and take
up to 26 options per choice. Questions and choice options keep the order you
give them:

```rust
use maple_sdk::{SystemOneAnswer, SystemOneQuestion, SystemOneRequest};
Expand Down Expand Up @@ -198,8 +201,9 @@ if let Some(SystemOneAnswer::Choice { choice, confidence, .. }) = response.answe

The client's configured credential is used (API key or signed-in session);
`system_one_with_api_key` encrypts an explicit key inside one request. Optional
request fields are `images` (`data:` URLs shown with the state) and
`temperature` (`1` returns raw probabilities). A rejected request is
request fields are `images` (`data:` URLs shown with the state,
`glm-5-3-flash` only) and `temperature` (`1`, the decision models' default,
returns raw probabilities). A rejected request is
`Error::Api` with the backend's status (422 for schema problems, 413 for size
limits, chat's statuses for quota and capacity) and body;
`Error::api_error_code` returns its `system_one_*` code. The example
Expand Down
13 changes: 9 additions & 4 deletions sdk/rust/src/system_one.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,15 @@ impl SystemOneQuestion {
/// per-type limits.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SystemOneRequest {
/// Defaults to the only supported model, `glm-5-3-flash`.
/// `glm-5-3-flash` when unset. `decision-small`, `decision-medium` and
/// `decision-large` read text only and take at most 26 options per choice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Any JSON; embedded in the prompt as given.
pub state: Value,
pub questions: OrderedEntries<SystemOneQuestion>,
/// Up to 4 `data:` image URLs, 4 MiB each and 8 MiB in total, shown with the state.
/// Up to 4 `data:` image URLs, 4 MiB each and 8 MiB in total, shown with the state
/// (`glm-5-3-flash` only).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<String>,
/// Calibration temperature override; `1` returns the raw probabilities.
Expand Down Expand Up @@ -218,7 +220,9 @@ impl SystemOneRequest {
/// The answer to one question. Every variant reports the calibration
/// `temperature` applied and `option_mass`, the share of the model's next-token
/// probability that fell on the offered options (low values mean the prompt did
/// not fit the question well).
/// not fit the question well). The decision models report temperature 1 unless
/// the request sets one, and an `option_mass` of 1: they score only the offered
/// options.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SystemOneAnswer {
Expand Down Expand Up @@ -260,7 +264,8 @@ pub struct SystemOneUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_tokens: u64,
/// Upstream inference requests made: one per question, more for wide choices.
/// Upstream inference requests made: one per question on `glm-5-3-flash` (more for
/// wide choices), one per call on the decision models.
pub requests: u64,
}

Expand Down
19 changes: 15 additions & 4 deletions sdk/src/lib/systemOne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ export type SystemOneQuestion =
SystemOneNoulQuestion | SystemOneChoiceQuestion | SystemOneScoreQuestion;

export type SystemOneRequest = {
/** Defaults to the only supported model, `glm-5-3-flash`. */
/**
* `glm-5-3-flash` when unset. `decision-small`, `decision-medium` and
* `decision-large` read text only and take at most 26 options per choice.
*/
model?: string;
/** Any JSON; embedded in the prompt as given. */
state: unknown;
/** Up to 64 questions, answered in the order given. */
questions: Record<string, SystemOneQuestion>;
/** Up to 4 `data:` image URLs, 4 MiB each and 8 MiB in total, shown with the state. */
/**
* Up to 4 `data:` image URLs, 4 MiB each and 8 MiB in total, shown with the
* state (`glm-5-3-flash` only).
*/
images?: string[];
/** Calibration temperature override; `1` returns the raw probabilities. */
temperature?: number;
Expand All @@ -59,7 +65,9 @@ export type SystemOneRequest = {
/**
* Every answer reports the calibration `temperature` applied and `option_mass`,
* the share of the model's next-token probability that fell on the offered
* options (low values mean the prompt did not fit the question well).
* options (low values mean the prompt did not fit the question well). The
* decision models report temperature 1 unless the request sets one, and an
* `option_mass` of 1: they score only the offered options.
*/
export type SystemOneNoulAnswer = {
type: "noul";
Expand Down Expand Up @@ -107,7 +115,10 @@ export type SystemOneUsage = {
input_tokens: number;
output_tokens: number;
cached_tokens: number;
/** Upstream inference requests made: one per question, more for wide choices. */
/**
* Upstream inference requests made: one per question on `glm-5-3-flash` (more
* for wide choices), one per call on the decision models.
*/
requests: number;
};

Expand Down
45 changes: 35 additions & 10 deletions services/opensecret/src/inference/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,16 +260,26 @@ impl ShadowHealthState {
let mut route_health = HashMap::new();
let mut capacity = HashMap::new();

for model in registry.completion_models() {
for route in model.routes {
let route_key = RouteKey {
provider: route.provider,
provider_model_id: route.provider_model_id.to_string(),
};
let pool = CapacityPoolKey::ProviderModel(route_key.clone());
route_health.entry(route_key.clone()).or_default();
capacity.entry(pool).or_default();
}
// Decision-model routes serve System One only, but their health and capacity are
// tracked the same way.
let routes = registry
.completion_models()
.iter()
.flat_map(|model| model.routes)
.chain(
registry
.decision_models()
.iter()
.flat_map(|model| model.routes),
);
for route in routes {
let route_key = RouteKey {
provider: route.provider,
provider_model_id: route.provider_model_id.to_string(),
};
let pool = CapacityPoolKey::ProviderModel(route_key.clone());
route_health.entry(route_key.clone()).or_default();
capacity.entry(pool).or_default();
}

Self {
Expand Down Expand Up @@ -2452,6 +2462,21 @@ mod tests {
assert_eq!(state.cardinality(), cardinality);
}

#[test]
fn decision_model_routes_are_tracked_like_completion_routes() {
let state = ShadowHealthState::new(&crate::provider_registry::PROVIDER_REGISTRY);
for model in crate::provider_registry::PROVIDER_REGISTRY.decision_models() {
let id = model.public_model_id;
let route = route(ProviderId::Tinfoil, id, id);
match state.try_claim_probe(&route.route_key()) {
ProbeClaimResult::Ready(_) => {}
other => panic!("decision route {id} must be known, got {other:?}"),
}
let report = state.observe_terminal(&completed(route), ShadowObservationMode::Update);
assert_ne!(report.signal, ShadowSignal::UnknownRoute, "{id}");
}
}

#[test]
fn maple_replica_probe_leases_and_recovery_do_not_cross_instance_boundaries() {
let state = ShadowHealthState::new(&DUAL_INSTANCE_TEST_REGISTRY);
Expand Down
77 changes: 77 additions & 0 deletions services/opensecret/src/model_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,51 @@ pub const POWERFUL_MODEL_ID: &str = GLM_5_3_MODEL_ID;
pub const KIMI_K3_MODEL_ID: &str = "kimi-k3";
pub const DEEPSEEK_V4_1_FLASH_MODEL_ID: &str = "deepseek-v4-1-flash";
pub const DEEPSEEK_V4_1_FLASH_UNCENSORED_MODEL_ID: &str = "deepseek-v4-1-flash-1017-uncensored";
pub const DECISION_SMALL_MODEL_ID: &str = "decision-small";
pub const DECISION_MEDIUM_MODEL_ID: &str = "decision-medium";
pub const DECISION_LARGE_MODEL_ID: &str = "decision-large";

/// A typed-decision model: it answers System One questions and nothing else, so it is in
/// neither chat catalog and never resolves as a completion model. The limits are those of
/// the serving deployment (Tinfoil's vLLM d3 family behind `/v1/decisions`): text only, at
/// most 26 lettered options per choice, and one question's prompt within the context window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DecisionModel {
pub(crate) id: &'static str,
pub(crate) access: ModelAccessTier,
/// Tokens one question's prompt (state, question and options) may take.
pub(crate) context_window: usize,
pub(crate) max_choice_options: usize,
}

impl DecisionModel {
const fn pro(id: &'static str) -> Self {
Self {
id,
access: ModelAccessTier::Pro,
context_window: 32_768,
max_choice_options: 26,
}
}
}

const DECISION_MODELS: &[DecisionModel] = &[
DecisionModel::pro(DECISION_SMALL_MODEL_ID),
DecisionModel::pro(DECISION_MEDIUM_MODEL_ID),
DecisionModel::pro(DECISION_LARGE_MODEL_ID),
];

pub(crate) fn decision_model(model: &str) -> Option<DecisionModel> {
DECISION_MODELS
.iter()
.find(|entry| entry.id == model)
.copied()
}

#[cfg(test)]
pub(crate) fn decision_models() -> &'static [DecisionModel] {
DECISION_MODELS
}

const FREE_MODEL_ALIAS_TARGETS: ModelAliasTargets = ModelAliasTargets {
quick: QUICK_MODEL_ID,
Expand Down Expand Up @@ -709,6 +754,9 @@ impl ModelPlan {

pub(crate) fn allows_model(self, model: &str) -> bool {
let canonical = alias_target(model).unwrap_or(model);
if let Some(decision) = decision_model(canonical) {
return decision.access.is_available_to(self);
}
model_entry(canonical).is_none_or(|entry| entry.access.is_available_to(self))
}
}
Expand Down Expand Up @@ -1729,6 +1777,35 @@ mod tests {
}
}

#[test]
fn decision_models_are_pro_and_never_chat_models() {
let ids: Vec<&str> = decision_models().iter().map(|model| model.id).collect();
assert_eq!(
ids,
[
DECISION_SMALL_MODEL_ID,
DECISION_MEDIUM_MODEL_ID,
DECISION_LARGE_MODEL_ID
]
);
let catalog = model_catalog_response(ModelPlan::Paid, true);
let openai = openai_models_response(true);
for model in decision_models() {
assert_eq!(decision_model(model.id), Some(*model));
assert!(!ModelPlan::Free.allows_model(model.id), "{}", model.id);
assert!(ModelPlan::Paid.allows_model(model.id), "{}", model.id);
assert_eq!(model.context_window, 32_768);
assert_eq!(model.max_choice_options, 26);
// Not a completion model: chat and Responses cannot resolve it, and neither
// catalog lists it.
assert_eq!(resolve_public_model_id(model.id), None);
assert!(model_capabilities(model.id).is_none());
assert!(!has_model(&catalog, model.id));
assert!(!has_model(&openai, model.id));
}
assert_eq!(decision_model(GLM_5_3_FLASH_MODEL_ID), None);
}

#[test]
fn test_catalog_hides_api_only_models_and_includes_aliases() {
let response = model_catalog_response(ModelPlan::Free, false);
Expand Down
Loading
Loading