diff --git a/llm-router/Cargo.lock b/llm-router/Cargo.lock index 0f34e91c0..6775efc28 100644 --- a/llm-router/Cargo.lock +++ b/llm-router/Cargo.lock @@ -774,13 +774,14 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "clap", "futures", "iii-sdk", "regex", + "schemars", "serde", "serde_json", "sha2", diff --git a/llm-router/Cargo.toml b/llm-router/Cargo.toml index eaff972ab..cfb3fcdc6 100644 --- a/llm-router/Cargo.toml +++ b/llm-router/Cargo.toml @@ -21,6 +21,9 @@ path = "src/lib.rs" iii-sdk = "=0.19.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Same schemars major as iii-sdk 0.19.2 so the schemas we derive here are the +# exact draft-07 shapes the SDK publishes for typed handlers (see wire_schema). +schemars = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "signal"] } async-trait = "0.1" thiserror = "2" diff --git a/llm-router/src/lib.rs b/llm-router/src/lib.rs index 3ff63511f..b48f9010f 100644 --- a/llm-router/src/lib.rs +++ b/llm-router/src/lib.rs @@ -14,3 +14,4 @@ pub mod state; pub mod testkit; pub mod triggers; pub mod types; +pub mod wire_schema; diff --git a/llm-router/src/register.rs b/llm-router/src/register.rs index 638c0dfc8..c9cf47e02 100644 --- a/llm-router/src/register.rs +++ b/llm-router/src/register.rs @@ -25,12 +25,21 @@ use crate::config::entry::{read_entry_value, register_entry, EntryWriteLock}; use crate::config::on_changed::make_on_config_changed; use crate::config::schema::default_provider_schema; use crate::registry::availability::{make_on_worker_available, make_provider_list}; -use crate::registry::register::make_provider_register; +use crate::registry::register::{make_provider_register, RegisterInput}; use crate::registry::resolve::{make_provider_resolve, make_update_credential}; use crate::registry::store::RegistryStore; use crate::settings::{parse_settings, RouterSettings}; use crate::triggers; use crate::types::errors::{RouterCode, RouterError}; +use crate::types::router::{ + AbortRequest, AbortResponse, ChatRequest, ChatResponse, CompleteRequest, CompleteResponse, + ConfigChangedEvent, ModelsGetRequest, ModelsGetResponse, ModelsListRequest, ModelsListResponse, + ModelsReconcileRequest, ModelsReconcileResponse, ModelsSupportsRequest, ModelsSupportsResponse, + OkResponse, ProviderListRequest, ProviderListResponse, ProviderRegisterResponse, + ProviderResolveRequest, ProviderResolveResponse, RouteRequest, RouteResponse, + UpdateCredentialRequest, WorkerAvailableEvent, +}; +use crate::wire_schema::{schema_of, with_schemas}; pub struct RouterRefs { pub registry: Arc, @@ -64,95 +73,120 @@ pub async fn register_router(iii: III) -> Result { let (iii_for_chat, pipeline) = (iii.clone(), pipeline.clone()); iii.register_function( "router::chat", - RegisterFunction::new_async(move |raw: Value| { - let (iii, pipeline) = (iii_for_chat.clone(), pipeline.clone()); - async move { - let writer_ref = serde_json::from_value( - raw.get("writer_ref").cloned().unwrap_or(Value::Null), - ) - .map_err(|_| { - IIIError::from(RouterError::new( - RouterCode::InvalidRequest, - "writer_ref (direction write) is required", - )) - })?; - let call: ChatCall = serde_json::from_value(raw).map_err(|e| { - IIIError::from(RouterError::new(RouterCode::InvalidRequest, e.to_string())) - })?; - let sink = open_sink(&iii, &writer_ref).await?; - let result = pipeline.run(call, sink.clone()).await; - sink.close(); // the handler owns closing the caller's channel - result.map(|r| serde_json::to_value(r).expect("serializable response")) - } - }), + with_schemas::(RegisterFunction::new_async( + move |raw: Value| { + let (iii, pipeline) = (iii_for_chat.clone(), pipeline.clone()); + async move { + let writer_ref = serde_json::from_value( + raw.get("writer_ref").cloned().unwrap_or(Value::Null), + ) + .map_err(|_| { + IIIError::from(RouterError::new( + RouterCode::InvalidRequest, + "writer_ref (direction write) is required", + )) + })?; + let call: ChatCall = serde_json::from_value(raw).map_err(|e| { + IIIError::from(RouterError::new( + RouterCode::InvalidRequest, + e.to_string(), + )) + })?; + let sink = open_sink(&iii, &writer_ref).await?; + let result = pipeline.run(call, sink.clone()).await; + sink.close(); // the handler owns closing the caller's channel + result.map(|r| serde_json::to_value(r).expect("serializable response")) + } + }, + )), ); } iii.register_function( "router::complete", - RegisterFunction::new_async(make_complete(iii.clone(), pipeline.clone())), + with_schemas::(RegisterFunction::new_async( + make_complete(iii.clone(), pipeline.clone()), + )), ); iii.register_function( "router::abort", - RegisterFunction::new_async(make_abort(inflight.clone())), + with_schemas::(RegisterFunction::new_async(make_abort( + inflight.clone(), + ))), ); iii.register_function( "router::models::list", - RegisterFunction::new_async(make_models_list(catalog.clone())), + with_schemas::(RegisterFunction::new_async( + make_models_list(catalog.clone()), + )), ); iii.register_function( "router::models::get", - RegisterFunction::new_async(make_models_get(catalog.clone())), + // Answers `{ model }` when the model is registered, or a bare `null` + // when it is not (the cold-window signal); publish that union. + RegisterFunction::new_async(make_models_get(catalog.clone())) + .request_format(schema_of::()) + .response_format(json!({ + "anyOf": [schema_of::(), { "type": "null" }] + })), ); iii.register_function( "router::models::supports", - RegisterFunction::new_async(make_models_supports(catalog.clone())), + with_schemas::(RegisterFunction::new_async( + make_models_supports(catalog.clone()), + )), ); iii.register_function( "router::provider::list", - RegisterFunction::new_async(make_provider_list(iii.clone(), registry.clone())), + with_schemas::(RegisterFunction::new_async( + make_provider_list(iii.clone(), registry.clone()), + )), ); iii.register_function( "router::route", - RegisterFunction::new_async(crate::routing::make_route( - registry.clone(), - catalog.clone(), - settings.clone(), + with_schemas::(RegisterFunction::new_async( + crate::routing::make_route(registry.clone(), catalog.clone(), settings.clone()), )), ); iii.register_function( "router::provider::register", - RegisterFunction::new_async(make_provider_register( - iii.clone(), - registry.clone(), - catalog.clone(), - entry_lock.clone(), + with_schemas::(RegisterFunction::new_async( + make_provider_register( + iii.clone(), + registry.clone(), + catalog.clone(), + entry_lock.clone(), + ), )), ); iii.register_function( "router::provider::resolve", - RegisterFunction::new_async(make_provider_resolve(iii.clone(), registry.clone())), + with_schemas::( + RegisterFunction::new_async(make_provider_resolve(iii.clone(), registry.clone())), + ), ); iii.register_function( "router::provider::update_credential", - RegisterFunction::new_async(make_update_credential( - iii.clone(), - registry.clone(), - entry_lock, + with_schemas::(RegisterFunction::new_async( + make_update_credential(iii.clone(), registry.clone(), entry_lock), )), ); iii.register_function( "router::models::reconcile", - RegisterFunction::new_async(make_models_reconcile( - iii.clone(), - registry.clone(), - catalog.clone(), - )), + with_schemas::( + RegisterFunction::new_async(make_models_reconcile( + iii.clone(), + registry.clone(), + catalog.clone(), + )), + ), ); // 5. bound triggers: topology + configuration change (paste-a-key) iii.register_function( "router::on_worker_available", - RegisterFunction::new_async(make_on_worker_available(iii.clone(), registry.clone())), + with_schemas::(RegisterFunction::new_async( + make_on_worker_available(iii.clone(), registry.clone()), + )), ); let _ = iii.register_trigger(RegisterTriggerInput { trigger_type: "subscribe".into(), @@ -175,11 +209,8 @@ pub async fn register_router(iii: III) -> Result { }); iii.register_function( "router::on_config_changed", - RegisterFunction::new_async(make_on_config_changed( - iii.clone(), - lookup, - settings.clone(), - 2000, + with_schemas::(RegisterFunction::new_async( + make_on_config_changed(iii.clone(), lookup, settings.clone(), 2000), )), ); } diff --git a/llm-router/src/registry/register.rs b/llm-router/src/registry/register.rs index 86d3846e5..e5ec19f85 100644 --- a/llm-router/src/registry/register.rs +++ b/llm-router/src/registry/register.rs @@ -20,11 +20,14 @@ use crate::config::schema::{default_provider_schema, validate_custom_schema}; use crate::registry::store::RegistryStore; use crate::triggers; -#[derive(Deserialize)] -struct RegisterInput { +/// Input of the `router::provider::register` iii function: a provider's +/// self-declaration plus an optional re-registration `token`. `pub` so the +/// boot wiring can publish its JSON Schema (`wire_schema`). +#[derive(Deserialize, schemars::JsonSchema)] +pub struct RegisterInput { #[serde(flatten)] - declaration: ProviderDeclaration, - token: Option, + pub declaration: ProviderDeclaration, + pub token: Option, } fn valid_id(id: &str) -> bool { diff --git a/llm-router/src/types/content.rs b/llm-router/src/types/content.rs index 2af136001..cab43e49e 100644 --- a/llm-router/src/types/content.rs +++ b/llm-router/src/types/content.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; /// Content blocks — the atomic units of message content (README § Content blocks). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { Text { diff --git a/llm-router/src/types/credential.rs b/llm-router/src/types/credential.rs index 5e9afaf36..184ac9c96 100644 --- a/llm-router/src/types/credential.rs +++ b/llm-router/src/types/credential.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Credential { ApiKey { diff --git a/llm-router/src/types/events.rs b/llm-router/src/types/events.rs index 6d0d83d9a..83673a1bb 100644 --- a/llm-router/src/types/events.rs +++ b/llm-router/src/types/events.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::types::messages::AssistantMessage; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum StopReason { End, @@ -12,7 +12,7 @@ pub enum StopReason { Error, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ErrorKind { AuthExpired, @@ -29,7 +29,7 @@ impl ErrorKind { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Usage { #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -47,7 +47,7 @@ pub struct Usage { /// The frozen 15-variant streaming vocabulary (README § Streaming events). /// New frame types are a contract revision, not a provider choice. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AssistantMessageEvent { Start { diff --git a/llm-router/src/types/messages.rs b/llm-router/src/types/messages.rs index a8e3919db..cc5a11d48 100644 --- a/llm-router/src/types/messages.rs +++ b/llm-router/src/types/messages.rs @@ -5,35 +5,35 @@ use crate::types::events::{ErrorKind, StopReason, Usage}; /// Single-variant role tags: exact-match on deserialize, correct wire string on /// serialize, and they let `AgentMessage` be an untagged union. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub enum UserRoleTag { #[serde(rename = "user")] User, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub enum AssistantRoleTag { #[serde(rename = "assistant")] Assistant, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub enum FunctionResultRoleTag { #[serde(rename = "function_result")] FunctionResult, } -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub enum CustomRoleTag { #[serde(rename = "custom")] Custom, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct UserMessage { pub role: UserRoleTag, pub content: Vec, pub timestamp: i64, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AssistantMessage { pub role: AssistantRoleTag, pub content: Vec, @@ -53,7 +53,7 @@ pub struct AssistantMessage { pub timestamp: i64, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct FunctionResultMessage { pub role: FunctionResultRoleTag, pub function_call_id: String, @@ -64,7 +64,7 @@ pub struct FunctionResultMessage { pub timestamp: i64, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct CustomMessage { pub role: CustomRoleTag, pub custom_type: String, // app-defined discriminator @@ -78,7 +78,7 @@ pub struct CustomMessage { /// The canonical transcript message union. Untagged: the single-variant role /// tags disambiguate deserialization. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(untagged)] pub enum AgentMessage { Assistant(AssistantMessage), diff --git a/llm-router/src/types/model.rs b/llm-router/src/types/model.rs index 1bc997512..9309a3784 100644 --- a/llm-router/src/types/model.rs +++ b/llm-router/src/types/model.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; /// "minimal" requests the lowest reasoning effort and needs only `thinking` /// support; levels map to provider-native knobs via `Model::thinking_budgets`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema, +)] #[serde(rename_all = "lowercase")] pub enum ThinkingLevel { Minimal, @@ -13,7 +15,7 @@ pub enum ThinkingLevel { Xhigh, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Pricing { #[serde(skip_serializing_if = "Option::is_none")] pub input: Option, @@ -26,7 +28,7 @@ pub struct Pricing { } /// The capability record (README § Model descriptor). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Model { pub id: String, pub provider: String, @@ -57,7 +59,7 @@ pub struct Model { /// Function invocation schema — what a provider sees as a `tools` array entry /// (README § Function invocation schema; adapter boundary). These describe iii /// functions exposed to the model, not provider-native tools. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AgentFunction { pub name: String, pub description: String, diff --git a/llm-router/src/types/router.rs b/llm-router/src/types/router.rs index 93794cd77..f20b169ea 100644 --- a/llm-router/src/types/router.rs +++ b/llm-router/src/types/router.rs @@ -10,7 +10,7 @@ use iii_sdk::StreamChannelRef; // ── consumer surface ──────────────────────────────────────────────────────── -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ResponseFormat { pub r#type: String, // "json" #[serde(skip_serializing_if = "Option::is_none")] @@ -19,7 +19,7 @@ pub struct ResponseFormat { /// Input of the `router::chat` iii function. /// (No `PartialEq`: `iii_sdk::StreamChannelRef` doesn't implement it.) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] pub struct ChatRequest { pub writer_ref: StreamChannelRef, // direction "write"; the caller's channel #[serde(skip_serializing_if = "Option::is_none")] @@ -44,13 +44,13 @@ pub struct ChatRequest { pub metadata: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ErrorShape { pub code: String, pub message: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ChatResponse { pub ok: bool, pub provider: String, @@ -64,7 +64,7 @@ pub struct ChatResponse { } /// Output of the `router::complete` iii function (non-streaming convenience). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct CompleteResponse { pub message: AssistantMessage, #[serde(skip_serializing_if = "Option::is_none")] @@ -74,16 +74,16 @@ pub struct CompleteResponse { } /// Input of the `router::abort` iii function. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AbortRequest { pub request_id: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AbortResponse { pub aborted: bool, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderInfo { pub id: String, pub display_name: String, @@ -91,14 +91,14 @@ pub struct ProviderInfo { pub available: bool, pub supports_model_listing: bool, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderListResponse { pub providers: Vec, } // ── provider protocol ─────────────────────────────────────────────────────── -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderDefaults { #[serde(skip_serializing_if = "Option::is_none")] pub api_url: Option, @@ -110,7 +110,7 @@ pub struct ProviderDefaults { /// Input of the `router::provider::register` iii function — a provider /// worker's self-declaration at attach time. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderDeclaration { pub id: String, // also the provider::::* prefix and config key #[serde(skip_serializing_if = "Option::is_none")] @@ -131,14 +131,14 @@ pub struct ProviderDeclaration { /// registration_token: spec adaptation — the engine exposes no caller identity, /// so identity binding is a bearer token; only its sha256 hash is persisted. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderRegisterResponse { pub ok: bool, pub id: String, pub registration_token: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "lowercase")] pub enum CredentialSource { Config, @@ -147,7 +147,7 @@ pub enum CredentialSource { } /// Output of the `router::provider::resolve` iii function. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderResolveResponse { pub configured: bool, pub source: CredentialSource, @@ -159,7 +159,7 @@ pub struct ProviderResolveResponse { } /// Output of the `router::models::reconcile` iii function. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ModelsReconcileResponse { pub provider: String, pub count: usize, @@ -168,7 +168,7 @@ pub struct ModelsReconcileResponse { /// Input of a provider worker's `provider::::stream` iii function — /// what the router forwards per attempt. /// (No `PartialEq`: `iii_sdk::StreamChannelRef` doesn't implement it.) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderStreamInput { pub writer_ref: StreamChannelRef, // direction "write" (router-owned in relay mode) #[serde(skip_serializing_if = "Option::is_none")] @@ -194,15 +194,192 @@ pub struct ProviderStreamInput { // ── event payloads ────────────────────────────────────────────────────────── /// Payload published on the `router::models::changed` pubsub topic. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ModelsChangedPayload { pub provider: String, pub count: usize, } /// Payload published on the `router::provider::changed` pubsub topic. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct ProviderChangedPayload { pub provider: String, pub op: String, // "register" | "available" | "unavailable" } + +// ── published-schema request/response shapes ───────────────────────────────── +// +// These describe the wire surface for `router::*` functions whose handlers +// stay on `serde_json::Value` (tolerant parsing, streaming sinks, bare-null +// answers — see `wire_schema`). They are schema-only: the handlers do not +// deserialize into them, so the runtime contract is unchanged. Keeping them +// here, next to the response types, makes the published API reference precise +// instead of "unknown". + +/// Input of the `router::complete` iii function — the chat input without the +/// streaming `writer_ref` (complete owns an internal channel). Mirrors +/// `ChatRequest` field-for-field minus `writer_ref`. +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub struct CompleteRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Input of the `router::route` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RouteRequest { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, +} +/// Output of the `router::route` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RouteResponse { + pub provider: String, + pub candidates: Vec, +} + +/// Input of the `router::models::list` iii function (both filters optional). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsListRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capability: Option, +} +/// Output of the `router::models::list` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsListResponse { + pub models: Vec, +} + +/// Input of the `router::models::get` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsGetRequest { + pub provider: String, + pub id: String, +} +/// Success envelope of `router::models::get`. The function answers with this +/// object when the model is registered, or a bare `null` when it is not (the +/// cold-window signal) — see `wire_schema` for the published `anyOf`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsGetResponse { + pub model: Model, +} + +/// Input of the `router::models::supports` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsSupportsRequest { + pub provider: String, + pub id: String, + pub capability: String, +} +/// Output of the `router::models::supports` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsSupportsResponse { + pub supported: bool, +} + +/// Input of the `router::provider::list` iii function — takes no parameters. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ProviderListRequest {} + +/// Input of the `router::provider::resolve` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ProviderResolveRequest { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, // registration bearer token +} + +/// Input of the `router::provider::update_credential` iii function. The +/// `credential` stays an opaque object (validated `is_object()` by the +/// handler) so the existing `router/invalid_request` message is preserved. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct UpdateCredentialRequest { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + // Runtime stays `Value` (the handler forwards it verbatim after an + // `is_object()` gate); the published schema describes that object contract + // rather than the permissive any-value `Value` renders as. + #[schemars(with = "BTreeMap")] + pub credential: Value, +} + +/// Input of the `router::models::reconcile` iii function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ModelsReconcileRequest { + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + pub models: Vec, +} + +/// A bare `{ "ok": true }` acknowledgement. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct OkResponse { + pub ok: bool, +} + +/// Engine topology event delivered to `router::on_worker_available` on the +/// `engine::workers-available` subscribe trigger. Permissive on purpose — +/// the handler tolerates several shapes and ignores the rest. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkerAvailableEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, +} + +/// Engine configuration-change event delivered to `router::on_config_changed` +/// on the `configuration:updated` trigger for the `llm-router` entry. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ConfigChangedEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub new_value: Option, // the full updated operator config entry +} + +// ── provider protocol acks (returned by provider workers) ──────────────────── + +/// Synchronous acknowledgement returned by `provider::::stream` and +/// `provider::::on_router_ready` — the real product of `stream` is the +/// frames written to the caller's channel, not this value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ProviderAck { + pub ok: bool, +} + +/// Acknowledgement returned by `provider::::refresh_models`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RefreshModelsAck { + pub ok: bool, + pub count: usize, +} + +/// A function that takes no caller parameters (e.g. `refresh_models`, or a +/// re-declare handler fired by an engine pubsub event it ignores). Published +/// as an empty object schema rather than the permissive `AnyValue`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct NoParams {} diff --git a/llm-router/src/wire_schema.rs b/llm-router/src/wire_schema.rs new file mode 100644 index 000000000..4f90def99 --- /dev/null +++ b/llm-router/src/wire_schema.rs @@ -0,0 +1,37 @@ +//! Published wire schemas for the `router::*` function surface. +//! +//! Why this exists: the router's handlers are deliberately tolerant — they +//! accept `serde_json::Value`, apply defaults, forward `messages` verbatim, +//! own a streaming `writer_ref` sink, and (for `models::get`) answer with a +//! bare `null`. None of that survives a strict typed `Fn(Req) -> Resp` +//! signature without changing the wire contract. So instead of retyping the +//! handlers, we keep them on `Value` and attach precise request/response JSON +//! Schemas explicitly via [`RegisterFunction::request_format`] / +//! [`response_format`]. Without this the SDK auto-extracts the permissive +//! `AnyValue` schema from `Fn(Value) -> Value`, which renders as "unknown" on +//! the workers.iii.dev API reference. +use iii_sdk::RegisterFunction; +use schemars::JsonSchema; +use serde_json::Value; + +/// Draft-07 JSON Schema for `T`. +/// +/// Generator settings mirror iii-sdk's internal `json_schema_for` +/// (`SchemaSettings::draft07()`), so an explicit override here is byte-for-byte +/// what a typed handler of the same type would have auto-extracted. +pub fn schema_of() -> Value { + serde_json::to_value( + schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(), + ) + .expect("a JsonSchema type always serializes to a JSON value") +} + +/// Attach precise request/response schemas to a registration whose handler +/// stays `Fn(Value) -> Value`. Dispatch is unchanged; only the published +/// schema surface gains structure. +pub fn with_schemas(f: RegisterFunction) -> RegisterFunction { + f.request_format(schema_of::()) + .response_format(schema_of::()) +} diff --git a/llm-router/tests/schemas.rs b/llm-router/tests/schemas.rs new file mode 100644 index 000000000..d58b7b778 --- /dev/null +++ b/llm-router/tests/schemas.rs @@ -0,0 +1,140 @@ +//! Golden coverage for the published `router::*` wire schemas. +//! +//! Regression guard: the router's functions were registered with +//! `Fn(Value) -> Value` handlers, so the SDK auto-extracted the permissive +//! `AnyValue` schema (which serializes to the JSON literal `true`) and the +//! workers.iii.dev API reference rendered "unknown". These tests assert every +//! request/response type produces a *structured* schema instead. +use llm_router::types::router::{ + AbortRequest, AbortResponse, ChatRequest, ChatResponse, CompleteRequest, CompleteResponse, + ConfigChangedEvent, ModelsGetRequest, ModelsGetResponse, ModelsListRequest, ModelsListResponse, + ModelsReconcileRequest, ModelsReconcileResponse, ModelsSupportsRequest, ModelsSupportsResponse, + OkResponse, ProviderAck, ProviderListRequest, ProviderListResponse, ProviderRegisterResponse, + ProviderResolveRequest, ProviderResolveResponse, ProviderStreamInput, RefreshModelsAck, + RouteRequest, RouteResponse, UpdateCredentialRequest, WorkerAvailableEvent, +}; +use llm_router::wire_schema::schema_of; +use schemars::JsonSchema; +use serde_json::Value; + +/// A structured schema is a JSON object with a recognizable schema keyword — +/// never the bare `true` that `serde_json::Value` (AnyValue) produces. +fn assert_structured(schema: &Value, label: &str) { + assert!( + schema.is_object(), + "{label}: schema must be a JSON object, got {schema} (AnyValue/`true` means the handler is still untyped)" + ); + let obj = schema.as_object().unwrap(); + let has_shape = obj.contains_key("type") + || obj.contains_key("properties") + || obj.contains_key("oneOf") + || obj.contains_key("anyOf") + || obj.contains_key("enum") + || obj.contains_key("$ref"); + assert!( + has_shape, + "{label}: schema lacks any structural keyword: {schema}" + ); +} + +fn props(schema: &Value) -> Vec { + schema + .get("properties") + .and_then(Value::as_object) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() +} + +fn schema_named(label: &str) -> Value { + let s = schema_of::(); + assert_structured(&s, label); + s +} + +#[test] +fn every_request_and_response_type_publishes_a_structured_schema() { + // request types + schema_named::("ChatRequest"); + schema_named::("CompleteRequest"); + schema_named::("AbortRequest"); + schema_named::("RouteRequest"); + schema_named::("ModelsListRequest"); + schema_named::("ModelsGetRequest"); + schema_named::("ModelsSupportsRequest"); + schema_named::("ProviderListRequest"); + schema_named::("ProviderResolveRequest"); + schema_named::("UpdateCredentialRequest"); + schema_named::("ModelsReconcileRequest"); + schema_named::("WorkerAvailableEvent"); + schema_named::("ConfigChangedEvent"); + schema_named::("ProviderStreamInput"); + + // response types + schema_named::("ChatResponse"); + schema_named::("CompleteResponse"); + schema_named::("AbortResponse"); + schema_named::("RouteResponse"); + schema_named::("ModelsListResponse"); + schema_named::("ModelsGetResponse"); + schema_named::("ModelsSupportsResponse"); + schema_named::("ProviderListResponse"); + schema_named::("ProviderRegisterResponse"); + schema_named::("ProviderResolveResponse"); + schema_named::("ModelsReconcileResponse"); + schema_named::("OkResponse"); + schema_named::("ProviderAck"); + schema_named::("RefreshModelsAck"); +} + +#[test] +fn chat_request_carries_the_streaming_writer_ref() { + let p = props(&schema_of::()); + for field in ["writer_ref", "model", "messages"] { + assert!( + p.contains(&field.to_string()), + "ChatRequest must expose `{field}`: {p:?}" + ); + } +} + +#[test] +fn complete_request_is_chat_without_writer_ref() { + // `router::complete` owns an internal channel, so its published request + // must NOT advertise a caller-supplied writer_ref. + let p = props(&schema_of::()); + assert!( + p.contains(&"model".to_string()), + "CompleteRequest must expose `model`: {p:?}" + ); + assert!( + p.contains(&"messages".to_string()), + "CompleteRequest must expose `messages`: {p:?}" + ); + assert!( + !p.contains(&"writer_ref".to_string()), + "CompleteRequest must NOT advertise writer_ref: {p:?}" + ); +} + +#[test] +fn provider_stream_input_is_the_full_provider_contract() { + let p = props(&schema_of::()); + for field in ["writer_ref", "model", "messages"] { + assert!( + p.contains(&field.to_string()), + "ProviderStreamInput must expose `{field}`: {p:?}" + ); + } +} + +#[test] +fn unit_response_schema_is_null_typed_not_anyvalue() { + // The trigger handlers (`on_worker_available`, `on_config_changed`) return + // Value::Null; their published response is the `()` schema. + let s = schema_of::<()>(); + assert_eq!( + s.get("type"), + Some(&Value::String("null".into())), + "unit schema must be null-typed: {s}" + ); +} diff --git a/provider-anthropic/Cargo.lock b/provider-anthropic/Cargo.lock index 93f6488a8..bd809b289 100644 --- a/provider-anthropic/Cargo.lock +++ b/provider-anthropic/Cargo.lock @@ -774,13 +774,14 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "clap", "futures", "iii-sdk", "regex", + "schemars", "serde", "serde_json", "sha2", diff --git a/provider-anthropic/src/register.rs b/provider-anthropic/src/register.rs index dc4172055..08ca6100c 100644 --- a/provider-anthropic/src/register.rs +++ b/provider-anthropic/src/register.rs @@ -5,7 +5,11 @@ use crate::discovery::{make_refresh_models, refresh_models}; use crate::stream_fn::make_stream; use crate::{router_client, state, PROVIDER_ID}; use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, III}; -use llm_router::types::router::{ProviderDeclaration, ProviderDefaults}; +use llm_router::types::router::{ + NoParams, ProviderAck, ProviderDeclaration, ProviderDefaults, ProviderStreamInput, + RefreshModelsAck, +}; +use llm_router::wire_schema::with_schemas; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::time::Duration; @@ -107,11 +111,16 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { iii.register_function( "provider::anthropic::stream", - RegisterFunction::new_async(make_stream(iii.clone(), http.clone())), + with_schemas::(RegisterFunction::new_async(make_stream( + iii.clone(), + http.clone(), + ))), ); iii.register_function( "provider::anthropic::refresh_models", - RegisterFunction::new_async(make_refresh_models(iii.clone(), http.clone())), + with_schemas::(RegisterFunction::new_async( + make_refresh_models(iii.clone(), http.clone()), + )), ); // Re-declare when the router restarts: router::ready rides iii-pubsub. @@ -120,13 +129,15 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { let http_ready = http.clone(); iii.register_function( "provider::anthropic::on_router_ready", - RegisterFunction::new_async(move |_raw: Value| { - let (iii, http) = (iii_ready.clone(), http_ready.clone()); - async move { - tokio::spawn(declare_and_refresh(iii, http)); - Ok(json!({ "ok": true })) - } - }), + with_schemas::(RegisterFunction::new_async( + move |_raw: Value| { + let (iii, http) = (iii_ready.clone(), http_ready.clone()); + async move { + tokio::spawn(declare_and_refresh(iii, http)); + Ok(json!({ "ok": true })) + } + }, + )), ); } let _ = iii.register_trigger(RegisterTriggerInput { diff --git a/provider-anthropic/tests/schemas.rs b/provider-anthropic/tests/schemas.rs new file mode 100644 index 000000000..3037b4477 --- /dev/null +++ b/provider-anthropic/tests/schemas.rs @@ -0,0 +1,49 @@ +//! Golden coverage: `provider::anthropic::*` publish structured wire schemas +//! instead of the permissive `AnyValue` (rendered "unknown" on the API +//! reference) that `Fn(Value) -> Value` handlers auto-extract. The published +//! types come from the shared provider protocol in `llm_router`. +use llm_router::types::router::{NoParams, ProviderAck, ProviderStreamInput, RefreshModelsAck}; +use llm_router::wire_schema::schema_of; +use serde_json::Value; + +fn assert_structured(schema: &Value, label: &str) { + assert!( + schema.is_object(), + "{label}: schema must be a JSON object, got {schema}" + ); + let obj = schema.as_object().unwrap(); + assert!( + obj.contains_key("type") + || obj.contains_key("properties") + || obj.contains_key("oneOf") + || obj.contains_key("anyOf"), + "{label}: schema lacks a structural keyword: {schema}" + ); +} + +#[test] +fn published_function_schemas_are_structured() { + // provider::anthropic::stream + assert_structured(&schema_of::(), "stream req"); + assert_structured(&schema_of::(), "stream resp"); + // provider::anthropic::refresh_models + assert_structured(&schema_of::(), "refresh_models req"); + assert_structured(&schema_of::(), "refresh_models resp"); + // provider::anthropic::on_router_ready + assert_structured(&schema_of::(), "on_router_ready resp"); +} + +#[test] +fn stream_request_is_the_full_provider_contract() { + let props: Vec = schema_of::() + .get("properties") + .and_then(Value::as_object) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + for f in ["writer_ref", "model", "messages"] { + assert!( + props.contains(&f.to_string()), + "stream req must expose `{f}`: {props:?}" + ); + } +} diff --git a/provider-openai/Cargo.lock b/provider-openai/Cargo.lock index a061bddef..a52ac537a 100644 --- a/provider-openai/Cargo.lock +++ b/provider-openai/Cargo.lock @@ -774,17 +774,21 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llm-router" -version = "0.1.0" +version = "0.2.1" dependencies = [ "async-trait", + "clap", "futures", "iii-sdk", "regex", + "schemars", "serde", "serde_json", "sha2", "thiserror", "tokio", + "tracing", + "tracing-subscriber", "uuid", ] diff --git a/provider-openai/src/register.rs b/provider-openai/src/register.rs index d02d4f4b9..fc02a279f 100644 --- a/provider-openai/src/register.rs +++ b/provider-openai/src/register.rs @@ -5,7 +5,11 @@ use crate::discovery::{make_refresh_models, refresh_models}; use crate::stream_fn::make_stream; use crate::{router_client, state, PROVIDER_ID}; use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, III}; -use llm_router::types::router::{ProviderDeclaration, ProviderDefaults}; +use llm_router::types::router::{ + NoParams, ProviderAck, ProviderDeclaration, ProviderDefaults, ProviderStreamInput, + RefreshModelsAck, +}; +use llm_router::wire_schema::with_schemas; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::time::Duration; @@ -107,11 +111,16 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { iii.register_function( "provider::openai::stream", - RegisterFunction::new_async(make_stream(iii.clone(), http.clone())), + with_schemas::(RegisterFunction::new_async(make_stream( + iii.clone(), + http.clone(), + ))), ); iii.register_function( "provider::openai::refresh_models", - RegisterFunction::new_async(make_refresh_models(iii.clone(), http.clone())), + with_schemas::(RegisterFunction::new_async( + make_refresh_models(iii.clone(), http.clone()), + )), ); // Re-declare when the router restarts: router::ready rides iii-pubsub. @@ -120,13 +129,15 @@ pub async fn register_provider(iii: III) -> Result<(), IIIError> { let http_ready = http.clone(); iii.register_function( "provider::openai::on_router_ready", - RegisterFunction::new_async(move |_raw: Value| { - let (iii, http) = (iii_ready.clone(), http_ready.clone()); - async move { - tokio::spawn(declare_and_refresh(iii, http)); - Ok(json!({ "ok": true })) - } - }), + with_schemas::(RegisterFunction::new_async( + move |_raw: Value| { + let (iii, http) = (iii_ready.clone(), http_ready.clone()); + async move { + tokio::spawn(declare_and_refresh(iii, http)); + Ok(json!({ "ok": true })) + } + }, + )), ); } let _ = iii.register_trigger(RegisterTriggerInput { diff --git a/provider-openai/tests/schemas.rs b/provider-openai/tests/schemas.rs new file mode 100644 index 000000000..52fe3e0ff --- /dev/null +++ b/provider-openai/tests/schemas.rs @@ -0,0 +1,49 @@ +//! Golden coverage: `provider::openai::*` publish structured wire schemas +//! instead of the permissive `AnyValue` (rendered "unknown" on the API +//! reference) that `Fn(Value) -> Value` handlers auto-extract. The published +//! types come from the shared provider protocol in `llm_router`. +use llm_router::types::router::{NoParams, ProviderAck, ProviderStreamInput, RefreshModelsAck}; +use llm_router::wire_schema::schema_of; +use serde_json::Value; + +fn assert_structured(schema: &Value, label: &str) { + assert!( + schema.is_object(), + "{label}: schema must be a JSON object, got {schema}" + ); + let obj = schema.as_object().unwrap(); + assert!( + obj.contains_key("type") + || obj.contains_key("properties") + || obj.contains_key("oneOf") + || obj.contains_key("anyOf"), + "{label}: schema lacks a structural keyword: {schema}" + ); +} + +#[test] +fn published_function_schemas_are_structured() { + // provider::openai::stream + assert_structured(&schema_of::(), "stream req"); + assert_structured(&schema_of::(), "stream resp"); + // provider::openai::refresh_models + assert_structured(&schema_of::(), "refresh_models req"); + assert_structured(&schema_of::(), "refresh_models resp"); + // provider::openai::on_router_ready + assert_structured(&schema_of::(), "on_router_ready resp"); +} + +#[test] +fn stream_request_is_the_full_provider_contract() { + let props: Vec = schema_of::() + .get("properties") + .and_then(Value::as_object) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + for f in ["writer_ref", "model", "messages"] { + assert!( + props.contains(&f.to_string()), + "stream req must expose `{f}`: {props:?}" + ); + } +}