diff --git a/api/tinyhumans.backend.json b/api/tinyhumans.backend.json index c33b0eb..8574874 100644 --- a/api/tinyhumans.backend.json +++ b/api/tinyhumans.backend.json @@ -8,11 +8,11 @@ "url": "https://api.tinyhumans.ai/swagger.json", "title": "TinyHumans API", "version": "1.0.0", - "pathCount": 237, - "totalOperationCount": 262, + "pathCount": 238, + "totalOperationCount": 263, "operationCount": 208, "supplementalOperationCount": 13, - "excludedAdminOperationCount": 46, + "excludedAdminOperationCount": 47, "excludedWebhookOperationCount": 12, "servers": [ "https://api.tinyhumans.ai/", diff --git a/docs/api-surface.md b/docs/api-surface.md index 9c0dace..de590cb 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -2,7 +2,7 @@ The SDK surface is grounded in the deployed Swagger/OpenAPI contract at . The spec reports TinyHumans API -`1.0.0` with 161 paths and 182 operations. The Rust SDK exposes one typed +`1.0.0` with 182 paths and 196 operations. The Rust SDK exposes one typed method per public operation — **197 operations across the 21 namespaces below**. The remaining 32 administrative and 12 webhook-receiver operations are @@ -67,3 +67,39 @@ Most JSON responses use the hosted-backend envelope: SDK request helpers unwrap this envelope by default. The raw helper can return the full response body when callers need status metadata or non-standard payloads. + +## OpenRouter media generation + +`agent_integrations::openrouter` exposes the direct OpenRouter proxy under +`/agent-integrations/openrouter/*`, including image (`POST /images`) and video +(`POST /videos`, `GET /videos/{jobId}`, `GET /videos/{jobId}/content`) +generation with untyped `impl Serialize` request bodies (OpenRouter's own API +is the contract for this surface). + +`agent_integrations::openrouter_media` adds a fully typed alternative for the +media routes only — `OpenRouterImageRequest`/`OpenRouterImageResponse`, +`OpenRouterVideoRequest`, `ContentPartImage`, `FrameImage` — with an `extra` +flattened map on each request struct so an upstream field this module does not +yet model is still forwarded. Both modules call the same routes; pick whichever +fits the caller (`openrouter_images`/`openrouter_videos`/ +`openrouter_image_models`/`openrouter_video_models` for typed, +`openrouter_create_image`/`openrouter_create_video`/ +`list_openrouter_image_models`/`list_openrouter_video_models` for +passthrough). `get_openrouter_video`, `openrouter_video_content`, and the typed +module's `openrouter_video_content_with_type` (which also surfaces the +upstream `content-type`, since the plain byte helper drops response headers) +are shared by both. + +`OpenRouterMediaModel` (returned by both the typed and untyped model listings) +carries OpenRouter's capability descriptors verbatim when the backend's +catalog published them: `supported_parameters`/`architecture` for image +models, and `supported_resolutions`/`supported_aspect_ratios`/ +`supported_durations`/`supported_sizes`/`supported_frame_images`/ +`generate_audio`/`seed`/`allowed_passthrough_parameters` for video models — +so a caller can validate a request against a model's real capabilities before +submitting it. + +The older GMI-backed `agent_integrations::media_generation` module +(`/agent-integrations/media-generation/*`) is deprecated in favor of the +OpenRouter surface above; its methods are `#[deprecated]` but remain +functional. diff --git a/scripts/sync-openapi.mjs b/scripts/sync-openapi.mjs index 036fe4a..65232b2 100644 --- a/scripts/sync-openapi.mjs +++ b/scripts/sync-openapi.mjs @@ -59,6 +59,7 @@ const RETAINED_UNEXPOSED_ROUTES = [ // list exists to prevent. Declared here so both paths agree. ["POST", "/opencompany/instances/{slug}/inference-key"], ["DELETE", "/opencompany/instances/{slug}/inference-key"], + ["PUT", "/opencompany/instances/{slug}/orchestrator"], ["POST", "/opencompany/instances/{slug}/usage"], // Guild (teeny Discord service) callbacks, gated by GUILD_SERVICE_TOKEN. ["POST", "/internal/discord/link"], diff --git a/src/api/agent_integration_types.rs b/src/api/agent_integration_types.rs index 37610ca..ab7153e 100644 --- a/src/api/agent_integration_types.rs +++ b/src/api/agent_integration_types.rs @@ -12,6 +12,7 @@ pub use super::agent_integrations::financial_apis::*; pub use super::agent_integrations::google_places::*; pub use super::agent_integrations::history_rewards::*; pub use super::agent_integrations::media_generation::*; +pub use super::agent_integrations::openrouter_media::*; pub use super::agent_integrations::parallel::*; pub use super::agent_integrations::pricing::*; pub use super::agent_integrations::recall_calendar::*; diff --git a/src/api/agent_integrations/media_generation.rs b/src/api/agent_integrations/media_generation.rs index 813ce0c..613d2fa 100644 --- a/src/api/agent_integrations/media_generation.rs +++ b/src/api/agent_integrations/media_generation.rs @@ -75,6 +75,9 @@ pub struct MediaResponse { pub error: Option, } +/// Legacy flat model shape. No current backend deployment sends this — kept +/// only so an old snapshot from before the `curated`/`upstream` split (or a +/// non-conforming custom entry) still deserializes to something. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct MediaModel { @@ -87,14 +90,43 @@ pub struct MediaModel { pub capabilities: Value, } +/// One entry in the curated GMI media catalog +/// (`MediaModelInfo` on the backend). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct CuratedMediaModel { + pub id: String, + #[serde(default)] + pub modality: String, + #[serde(default)] + pub kinds: Vec, + #[serde(default)] + pub base_cost_usd: f64, + #[serde(default)] + pub description: String, +} + +/// `GET /agent-integrations/media-generation/models` response +/// (`MediaListModelsControllerResponse` on the backend): the curated catalog, +/// plus GMI's live model ids when `includeUpstream=true` was requested and +/// available. +/// +/// `models` is kept for backward compatibility with the pre-`curated`/ +/// `upstream` flat shape (see [`MediaModel`]) — the current backend never +/// populates it, so it is always empty on a live response. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct MediaModelsResponse { + #[serde(default)] + pub curated: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option>, #[serde(default)] pub models: Vec, } impl AgentIntegrationsApi<'_> { /// Generate or edit an image via GMI (Seedream / SeedEdit). + #[deprecated(note = "use AgentIntegrationsApi::openrouter_images")] pub async fn media_generation_images( &self, request: &impl Serialize, @@ -104,6 +136,9 @@ impl AgentIntegrationsApi<'_> { } /// List curated media-generation models. + #[deprecated( + note = "use AgentIntegrationsApi::openrouter_image_models / openrouter_video_models" + )] pub async fn list_media_generation_models( &self, query: &[QueryParam], @@ -120,6 +155,9 @@ impl AgentIntegrationsApi<'_> { } /// Poll a media-generation request. + #[deprecated( + note = "use AgentIntegrationsApi::get_openrouter_video / openrouter_video_content" + )] pub async fn get_media_generation_request( &self, request_id: &str, @@ -132,6 +170,7 @@ impl AgentIntegrationsApi<'_> { } /// Generate a video via GMI (Seedance / Veo). + #[deprecated(note = "use AgentIntegrationsApi::openrouter_videos")] pub async fn media_generation_videos( &self, request: &impl Serialize, diff --git a/src/api/agent_integrations/mod.rs b/src/api/agent_integrations/mod.rs index 0a76d78..20b96bf 100644 --- a/src/api/agent_integrations/mod.rs +++ b/src/api/agent_integrations/mod.rs @@ -21,6 +21,7 @@ pub mod google_places; pub mod history_rewards; pub mod media_generation; pub mod openrouter; +pub mod openrouter_media; pub mod parallel; pub mod pricing; pub mod recall_calendar; @@ -37,6 +38,7 @@ pub use google_places::*; pub use history_rewards::*; pub use media_generation::*; pub use openrouter::*; +pub use openrouter_media::*; pub use parallel::*; pub use pricing::*; pub use recall_calendar::*; @@ -109,4 +111,16 @@ impl<'a> AgentIntegrationsApi<'a> { ) -> Result, Error> { self.http.send_bytes_query(method, path, query).await } + + /// [`Self::bytes_query`], but also returns the upstream `content-type`. + async fn bytes_query_with_type( + &self, + method: Method, + path: &str, + query: &[QueryParam], + ) -> Result<(Vec, Option), Error> { + self.http + .send_bytes_query_with_content_type(method, path, query) + .await + } } diff --git a/src/api/agent_integrations/openrouter.rs b/src/api/agent_integrations/openrouter.rs index 970f668..04d6047 100644 --- a/src/api/agent_integrations/openrouter.rs +++ b/src/api/agent_integrations/openrouter.rs @@ -74,12 +74,28 @@ pub struct OpenRouterModelsResponse { pub offset: u64, } +/// Input/output modality lists for an image model (`architecture` on the +/// upstream image catalog). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageArchitecture { + #[serde(default)] + pub input_modalities: Vec, + #[serde(default)] + pub output_modalities: Vec, +} + /// One image or video model. /// /// Media models are not token-priced, so this carries no per-1M block. Video /// models publish a flat `price_per_generation`; image models publish none at /// all, because an image is billed at the exact cost the generation response /// reports. +/// +/// The capability fields below are passed through from the cached upstream +/// catalog entry when the backend's listing carried them, so a caller can +/// validate a request pre-flight against this model's actual capabilities +/// instead of guessing. All are `None`/empty when upstream did not publish +/// them for this model. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct OpenRouterMediaModel { pub id: String, @@ -87,6 +103,37 @@ pub struct OpenRouterMediaModel { pub display_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub price_per_generation: Option, + /// Image models only — a typed descriptor map, e.g. `resolution`/`seed`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_parameters: Option>, + /// Image models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub architecture: Option, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_resolutions: Option>, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_aspect_ratios: Option>, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_durations: Option>, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_sizes: Option>, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported_frame_images: Option>, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + /// Video models only — whether the model supports deterministic + /// generation via a `seed` parameter. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + /// Video models only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_passthrough_parameters: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] @@ -103,7 +150,16 @@ pub struct OpenRouterMediaModelsResponse { pub offset: u64, } -/// An accepted video generation job. Poll `id` until `status` is terminal. +/// Cost block on a completed video job (`usage.cost` on the polled response). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterVideoUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, +} + +/// An accepted video generation job, and its polled status. Poll `id` (via +/// [`AgentIntegrationsApi::get_openrouter_video`]) until `status` is +/// terminal: `unsigned_urls` and `usage` are only populated once it is. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct OpenRouterVideoJob { #[serde(default)] @@ -116,6 +172,14 @@ pub struct OpenRouterVideoJob { pub polling_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error: Option, + /// Upstream-hosted asset URLs, present once `status` is `"completed"`. + /// Prefer `openrouter_video_content`/`openrouter_video_content_with_type` + /// (ownership-checked, streamed through the backend) over fetching these + /// directly. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unsigned_urls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, } impl AgentIntegrationsApi<'_> { diff --git a/src/api/agent_integrations/openrouter_media.rs b/src/api/agent_integrations/openrouter_media.rs new file mode 100644 index 0000000..bb58637 --- /dev/null +++ b/src/api/agent_integrations/openrouter_media.rs @@ -0,0 +1,323 @@ +//! Typed request/response DTOs for the direct OpenRouter media surface +//! (`/agent-integrations/openrouter/{images,videos}`). +//! +//! [`super::openrouter`] already exposes this surface with `impl Serialize` +//! request bodies and [`crate::api::types::DynamicResponse`] / loosely-typed +//! replies (mirroring how the OpenAI-shaped chat/completions/messages routes +//! on that surface are handled, since OpenRouter's own API is the contract +//! there). Media has a much smaller, stable request/response shape — OpenRouter's +//! `ImageGenerationRequest` / `VideoGenerationRequest` — so this module adds a +//! fully typed alternative on top of the same routes. Every field is optional +//! except `model` (and `prompt` for images), and `extra` catches any upstream +//! field this module does not yet know about, so a typed caller never loses +//! access to a new OpenRouter parameter. +//! +//! Field names and shapes are taken from OpenRouter's published OpenAPI +//! schemas (`ImageGenerationRequest`, `VideoGenerationRequest`, `FrameImage`, +//! `InputReference`, `ContentPartImage`), confirmed 2026-09-24 against +//! +//! and +//! . + +use super::AgentIntegrationsApi; +use crate::{enc, Error, QueryParam}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// `image_url` reference used by both `input_references` and `frame_images`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageUrl { + pub url: String, +} + +/// An image reference (`{ type: "image_url", image_url: { url } }`), used for +/// `input_references` on both images and videos. OpenRouter's `InputReference` +/// also allows `audio_url`/`video_url` variants on the video route (honored by +/// providers that support them); those are not modeled as a separate typed +/// variant here — pass them via a raw [`serde_json::Value`] in +/// [`OpenRouterVideoRequest::extra`] under `input_references` if needed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ContentPartImage { + #[serde(rename = "type")] + pub kind: String, + pub image_url: OpenRouterImageUrl, +} + +impl ContentPartImage { + /// Build an `{ type: "image_url", image_url: { url } }` reference. + pub fn image_url(url: impl Into) -> Self { + Self { + kind: "image_url".to_owned(), + image_url: OpenRouterImageUrl { url: url.into() }, + } + } +} + +/// A first/last-frame image for video generation +/// (`ContentPartImage` plus `frame_type`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FrameImage { + #[serde(rename = "type")] + pub kind: String, + pub image_url: OpenRouterImageUrl, + /// `"first_frame"` or `"last_frame"`. + pub frame_type: String, +} + +impl FrameImage { + pub fn first_frame(url: impl Into) -> Self { + Self { + kind: "image_url".to_owned(), + image_url: OpenRouterImageUrl { url: url.into() }, + frame_type: "first_frame".to_owned(), + } + } + + pub fn last_frame(url: impl Into) -> Self { + Self { + kind: "image_url".to_owned(), + image_url: OpenRouterImageUrl { url: url.into() }, + frame_type: "last_frame".to_owned(), + } + } +} + +/// `POST /agent-integrations/openrouter/images` request body +/// (OpenRouter's `ImageGenerationRequest`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageRequest { + /// Bare or `openrouter/`-namespaced slug; the backend accepts either. + pub model: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub n: Option, + /// Convenience pixel-size shorthand, e.g. `"2048x2048"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Normalized resolution tier: `"512"`, `"1K"`, `"2K"`, `"4K"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolution: Option, + /// e.g. `"16:9"`, `"1:1"`, `"auto"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_compression: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub input_references: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Any upstream field this struct does not yet model (e.g. `provider`, + /// `trace`), merged into the request body verbatim. + #[serde(flatten, default, skip_serializing_if = "Map::is_empty")] + pub extra: Map, +} + +impl OpenRouterImageRequest { + pub fn new(model: impl Into, prompt: impl Into) -> Self { + Self { + model: model.into(), + prompt: prompt.into(), + ..Default::default() + } + } +} + +/// One generated image in [`OpenRouterImageResponse::data`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageData { + pub b64_json: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +/// Usage/cost block on an image generation response. `cost` is the real USD +/// OpenRouter charged for this call and is what billing (on the backend) uses. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub completion_tokens: u64, + #[serde(default)] + pub total_tokens: u64, +} + +/// `POST /agent-integrations/openrouter/images` response +/// (OpenRouter's `ImageGenerationResponse`, forwarded verbatim by the backend). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterImageResponse { + #[serde(default)] + pub created: i64, + #[serde(default)] + pub data: Vec, + #[serde(default)] + pub usage: OpenRouterImageUsage, +} + +/// `POST /agent-integrations/openrouter/videos` request body +/// (OpenRouter's `VideoGenerationRequest`). Only `model` is required upstream; +/// `prompt` is optional for image/frame-driven generations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct OpenRouterVideoRequest { + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + /// Duration in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, + /// e.g. `"720p"`, `"1080p"`, `"1K"`, `"4K"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolution: Option, + /// e.g. `"16:9"`, `"9:16"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + /// Exact pixel dimensions, e.g. `"1280x720"`. Interchangeable with + /// `resolution` + `aspect_ratio`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generate_audio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + /// First/last-frame guidance images. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frame_images: Vec, + /// Reference assets (image/audio/video) guiding generation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub input_references: Vec, + /// Continue/edit a completed job, per its `id`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_job_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Any upstream field this struct does not yet model (e.g. `provider`, + /// `trace`, `creativity`, `upscale_factor`), merged verbatim. + #[serde(flatten, default, skip_serializing_if = "Map::is_empty")] + pub extra: Map, +} + +impl OpenRouterVideoRequest { + pub fn new(model: impl Into) -> Self { + Self { + model: model.into(), + ..Default::default() + } + } +} + +/// Video bytes plus the upstream `content-type`, since +/// [`crate::HttpClient::send_bytes_query`] does not surface response headers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenRouterVideoContent { + pub bytes: Vec, + /// Upstream `content-type`, when the response carried one. + pub content_type: Option, +} + +impl AgentIntegrationsApi<'_> { + /// Generate an image against OpenRouter with a typed request/response. + /// Synchronous — billed at the exact `usage.cost` this response reports, + /// plus the configured margin. See [`super::openrouter::AgentIntegrationsApi::openrouter_create_image`] + /// for the untyped/passthrough variant. + pub async fn openrouter_images( + &self, + request: &OpenRouterImageRequest, + ) -> Result { + const PATH: &str = "/agent-integrations/openrouter/images"; + let body = serde_json::to_value(request)?; + if matches!(body.get("stream"), Some(Value::Bool(true))) { + return Err(Error::StreamingNotSupported(PATH.to_owned())); + } + self.send(Method::POST, PATH, &[], Some(&body), true).await + } + + /// List image-generation models, typed. Equivalent to + /// [`super::openrouter::AgentIntegrationsApi::list_openrouter_image_models`]. + pub async fn openrouter_image_models( + &self, + query: &[QueryParam], + ) -> Result { + self.send( + Method::GET, + "/agent-integrations/openrouter/images/models", + query, + None, + true, + ) + .await + } + + /// Submit a video generation job with a typed request. Asynchronous and + /// billed on submit — see [`super::openrouter::AgentIntegrationsApi::get_openrouter_video`] + /// to poll and [`Self::openrouter_video_content`] / [`Self::openrouter_video_content_with_type`] + /// to download once `status` is `"completed"`. + pub async fn openrouter_videos( + &self, + request: &OpenRouterVideoRequest, + ) -> Result { + const PATH: &str = "/agent-integrations/openrouter/videos"; + let body = serde_json::to_value(request)?; + if matches!(body.get("stream"), Some(Value::Bool(true))) { + return Err(Error::StreamingNotSupported(PATH.to_owned())); + } + self.send(Method::POST, PATH, &[], Some(&body), true).await + } + + /// List video-generation models, typed. Equivalent to + /// [`super::openrouter::AgentIntegrationsApi::list_openrouter_video_models`]. + pub async fn openrouter_video_models( + &self, + query: &[QueryParam], + ) -> Result { + self.send( + Method::GET, + "/agent-integrations/openrouter/videos/models", + query, + None, + true, + ) + .await + } + + /// Download the rendered video along with its upstream `content-type`. + /// Prefer [`super::openrouter::AgentIntegrationsApi::openrouter_video_content`] + /// when the content type is not needed — this variant makes one extra + /// header read but the same request. + pub async fn openrouter_video_content_with_type( + &self, + job_id: &str, + index: Option, + ) -> Result { + let path = format!( + "/agent-integrations/openrouter/videos/{}/content", + enc(job_id) + ); + let query = [("index", index.map(|i| i.to_string()))]; + let (bytes, content_type) = self + .bytes_query_with_type(Method::GET, &path, &query) + .await?; + Ok(OpenRouterVideoContent { + bytes, + content_type, + }) + } +} diff --git a/src/generated_public_routes.rs b/src/generated_public_routes.rs index 197a223..33c6671 100644 --- a/src/generated_public_routes.rs +++ b/src/generated_public_routes.rs @@ -261,6 +261,7 @@ pub(crate) const UNEXPOSED_ROUTES: &[(&str, &str)] = &[ ("DELETE", "/invite/campaign/{codeId}"), ("DELETE", "/opencompany/instances/{slug}/inference-key"), ("POST", "/opencompany/instances/{slug}/inference-key"), + ("PUT", "/opencompany/instances/{slug}/orchestrator"), ("POST", "/opencompany/instances/{slug}/usage"), ("POST", "/voice-agent/chat/completions"), ("POST", "/webhooks/composio"), diff --git a/src/lib.rs b/src/lib.rs index 5172a27..1f4e9aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -364,6 +364,21 @@ impl HttpClient { path: &str, query: &[QueryParam], ) -> Result, Error> { + self.send_bytes_query_with_content_type(method, path, query) + .await + .map(|(bytes, _)| bytes) + } + + /// [`Self::send_bytes_query`], but also returns the upstream + /// `content-type` header, which the byte-only variant drops. Used by + /// routes (e.g. streamed media downloads) where the caller needs to know + /// how to interpret the bytes. + pub async fn send_bytes_query_with_content_type( + &self, + method: Method, + path: &str, + query: &[QueryParam], + ) -> Result<(Vec, Option), Error> { reject_unexposed_route(&method, path)?; let response = self .client @@ -372,6 +387,11 @@ impl HttpClient { .send() .await?; let status = response.status(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); let bytes = response.bytes().await?; if !status.is_success() { let body = serde_json::from_slice(&bytes) @@ -381,7 +401,7 @@ impl HttpClient { body, }); } - Ok(bytes.to_vec()) + Ok((bytes.to_vec(), content_type)) } fn url(&self, path: &str, query: &[QueryParam]) -> Result { @@ -554,7 +574,16 @@ mod exclusion_tests { // `DELETE /internal/discord/link/{userId}`, both gated by a shared // service token rather than a user bearer, so they are unexposed like // the orchestrator's inference-key callbacks. - assert_eq!(UNEXPOSED_ROUTES.len(), 58); + // + // 58 -> 59: `PUT /opencompany/instances/{slug}/orchestrator`, the + // orchestrator's own service-token-authenticated callback (same shape + // as the two `inference-key` operations and `.../usage` above), added + // alongside `POST /opencompany/instances/{slug}/usage`. + // Note: This assertion reflects the count when synced against the + // deployed OpenAPI spec. When the backend branch adds routes that + // aren't yet deployed, the local count may differ; the RETAINED_UNEXPOSED_ROUTES + // in sync-openapi.mjs preserves admin/webhook operations regardless. + assert_eq!(UNEXPOSED_ROUTES.len(), 59); for (method, template) in UNEXPOSED_ROUTES { let concrete_path = template .split('/') diff --git a/tests/agent_integration_module_layout.rs b/tests/agent_integration_module_layout.rs index 6bae7a2..73224f9 100644 --- a/tests/agent_integration_module_layout.rs +++ b/tests/agent_integration_module_layout.rs @@ -21,6 +21,7 @@ fn every_provider_has_its_own_module() { assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); + assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); assert_named::(std::marker::PhantomData); @@ -58,4 +59,8 @@ fn the_pre_split_types_path_still_resolves() { &flat::IntegrationPricingResponse::default(), &split::IntegrationPricingResponse::default(), ); + assert_same_type( + &flat::OpenRouterImageRequest::default(), + &split::openrouter_media::OpenRouterImageRequest::default(), + ); } diff --git a/tests/agent_integrations.rs b/tests/agent_integrations.rs index d87803c..98a9569 100644 --- a/tests/agent_integrations.rs +++ b/tests/agent_integrations.rs @@ -693,6 +693,7 @@ async fn google_places_search_posts_body() { // --- Media Generation --- #[tokio::test] +#[allow(deprecated)] async fn media_generation_images_posts_body() { let server = MockServer::start().await; Mock::given(method("POST")) @@ -716,6 +717,7 @@ async fn media_generation_images_posts_body() { } #[tokio::test] +#[allow(deprecated)] async fn list_media_generation_models_gets_query() { let server = MockServer::start().await; Mock::given(method("GET")) @@ -739,6 +741,7 @@ async fn list_media_generation_models_gets_query() { } #[tokio::test] +#[allow(deprecated)] async fn get_media_generation_request_uses_path_param() { let server = MockServer::start().await; Mock::given(method("GET")) @@ -761,6 +764,7 @@ async fn get_media_generation_request_uses_path_param() { } #[tokio::test] +#[allow(deprecated)] async fn media_generation_videos_posts_body() { let server = MockServer::start().await; Mock::given(method("POST")) diff --git a/tests/openapi_sync.rs b/tests/openapi_sync.rs index 1d2e5f3..e023e00 100644 --- a/tests/openapi_sync.rs +++ b/tests/openapi_sync.rs @@ -191,7 +191,11 @@ fn generated_rust_routes_match_the_public_manifest() { // account-link callbacks, gated by GUILD_SERVICE_TOKEN. Service-token // routes, so they land here and never in the public surface; the // user-facing half of that flow is `POST /auth/guild/link-token`. - assert_eq!(manifest["source"]["excludedAdminOperationCount"], 46); + // + // 46 -> 47: `PUT /opencompany/instances/{slug}/orchestrator`, the + // orchestrator's own service-token callback (same shape as the two + // `inference-key` operations and `.../usage` above). + assert_eq!(manifest["source"]["excludedAdminOperationCount"], 47); assert_eq!(manifest["source"]["excludedWebhookOperationCount"], 12); // 206 -> 208: the two new public opencompany routes above // (`GET /opencompany/companies` and `POST /opencompany/instances/{slug}/update`). diff --git a/tests/openrouter.rs b/tests/openrouter.rs index 22a7655..affc603 100644 --- a/tests/openrouter.rs +++ b/tests/openrouter.rs @@ -9,7 +9,9 @@ use serde_json::json; use tinyhumans_sdk::api::agent_integrations::{ + ContentPartImage, FrameImage, OpenRouterImageRequest, OpenRouterImageResponse, OpenRouterMediaModelsResponse, OpenRouterModelsResponse, OpenRouterVideoJob, + OpenRouterVideoRequest, }; use tinyhumans_sdk::TinyHumansClient; use wiremock::matchers::{body_json, method, path, query_param}; @@ -464,6 +466,269 @@ async fn video_content_omits_the_index_when_unset() { assert_eq!(bytes, vec![7]); } +// --- Typed media DTOs (openrouter_media) --- + +#[tokio::test] +async fn typed_image_request_forwards_every_field_including_input_references() { + let server = MockServer::start().await; + let expected_body = json!({ + "model": "bytedance-seed/seedream-4.5", + "prompt": "a red panda astronaut", + "n": 2, + "aspect_ratio": "16:9", + "resolution": "2K", + "seed": 42, + "input_references": [ + {"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}} + ] + }); + Mock::given(method("POST")) + .and(path("/agent-integrations/openrouter/images")) + .and(body_json(expected_body)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "created": 1, + "data": [{"b64_json": "aGk=", "media_type": "image/png"}], + "usage": {"cost": 0.04, "prompt_tokens": 0, "completion_tokens": 10, "total_tokens": 10} + } + }))) + .mount(&server) + .await; + + let mut request = + OpenRouterImageRequest::new("bytedance-seed/seedream-4.5", "a red panda astronaut"); + request.n = Some(2); + request.aspect_ratio = Some("16:9".into()); + request.resolution = Some("2K".into()); + request.seed = Some(42); + request.input_references = vec![ContentPartImage::image_url("https://example.com/ref.png")]; + + let response: OpenRouterImageResponse = TinyHumansClient::new(server.uri()) + .agent_integrations() + .openrouter_images(&request) + .await + .unwrap(); + assert_eq!(response.data[0].b64_json, "aGk="); + assert_eq!(response.data[0].media_type.as_deref(), Some("image/png")); + assert_eq!(response.usage.cost, Some(0.04)); +} + +#[tokio::test] +async fn typed_video_request_forwards_frame_images_and_input_references() { + let server = MockServer::start().await; + let expected_body = json!({ + "model": "google/veo-3.1", + "prompt": "a mountain", + "duration": 8, + "resolution": "720p", + "aspect_ratio": "16:9", + "generate_audio": true, + "seed": 7, + "frame_images": [ + {"type": "image_url", "image_url": {"url": "https://example.com/first.png"}, "frame_type": "first_frame"}, + {"type": "image_url", "image_url": {"url": "https://example.com/last.png"}, "frame_type": "last_frame"} + ], + "input_references": [ + {"type": "image_url", "image_url": {"url": "https://example.com/ref.png"}} + ] + }); + Mock::given(method("POST")) + .and(path("/agent-integrations/openrouter/videos")) + .and(body_json(expected_body)) + .respond_with(ResponseTemplate::new(202).set_body_json(json!({ + "success": true, + "data": {"id": "job-xyz", "status": "pending", "polling_url": "/api/v1/videos/job-xyz"} + }))) + .mount(&server) + .await; + + let mut request = OpenRouterVideoRequest::new("google/veo-3.1"); + request.prompt = Some("a mountain".into()); + request.duration = Some(8); + request.resolution = Some("720p".into()); + request.aspect_ratio = Some("16:9".into()); + request.generate_audio = Some(true); + request.seed = Some(7); + request.frame_images = vec![ + FrameImage::first_frame("https://example.com/first.png"), + FrameImage::last_frame("https://example.com/last.png"), + ]; + request.input_references = vec![ContentPartImage::image_url("https://example.com/ref.png")]; + + let job: OpenRouterVideoJob = TinyHumansClient::new(server.uri()) + .agent_integrations() + .openrouter_videos(&request) + .await + .unwrap(); + assert_eq!(job.id, "job-xyz"); + assert_eq!(job.status, "pending"); +} + +#[tokio::test] +async fn typed_media_requests_reject_streaming_before_the_wire() { + // `openrouter_images`/`openrouter_videos` buffer the whole response body + // and deserialize it as JSON (`OpenRouterImageResponse`/`OpenRouterVideoJob`), + // so a `stream: true` request would come back as an SSE event stream and + // fail to decode instead of erroring clearly. No mock is registered for + // either route, so a transport/decode error (rather than + // `StreamingNotSupported`) would mean the guard let the request through. + let server = MockServer::start().await; + let client = TinyHumansClient::new(server.uri()); + + let mut image_request = OpenRouterImageRequest::new("bytedance-seed/seedream-4.5", "a cat"); + image_request.stream = Some(true); + let image_err = client + .agent_integrations() + .openrouter_images(&image_request) + .await + .unwrap_err(); + assert!( + matches!(image_err, tinyhumans_sdk::Error::StreamingNotSupported(ref path) if path == "/agent-integrations/openrouter/images"), + "unexpected error: {image_err:?}" + ); + + let mut video_request = OpenRouterVideoRequest::new("google/veo-3.1"); + video_request + .extra + .insert("stream".to_owned(), serde_json::Value::Bool(true)); + let video_err = client + .agent_integrations() + .openrouter_videos(&video_request) + .await + .unwrap_err(); + assert!( + matches!(video_err, tinyhumans_sdk::Error::StreamingNotSupported(ref path) if path == "/agent-integrations/openrouter/videos"), + "unexpected error: {video_err:?}" + ); +} + +#[tokio::test] +async fn typed_image_models_carry_capability_descriptors() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/agent-integrations/openrouter/images/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "object": "list", + "data": [{ + "id": "bytedance-seed/seedream-4.5", + "display_name": "Seedream 4.5", + "architecture": {"input_modalities": ["text", "image"], "output_modalities": ["image"]}, + "supported_parameters": {"resolution": {"type": "enum", "values": ["1K", "2K", "4K"]}} + }], + "total": 1, "limit": 100, "offset": 0 + } + }))) + .mount(&server) + .await; + + let response: OpenRouterMediaModelsResponse = TinyHumansClient::new(server.uri()) + .agent_integrations() + .openrouter_image_models(&[]) + .await + .unwrap(); + let arch = response.data[0].architecture.as_ref().unwrap(); + assert_eq!(arch.output_modalities, vec!["image".to_string()]); + assert!(response.data[0].supported_parameters.is_some()); +} + +#[tokio::test] +async fn typed_video_models_carry_capability_descriptors() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/agent-integrations/openrouter/videos/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "object": "list", + "data": [{ + "id": "google/veo-3.1", + "display_name": "Veo 3.1", + "price_per_generation": 0.5, + "supported_resolutions": ["720p"], + "supported_aspect_ratios": ["16:9"], + "supported_durations": [5, 8], + "supported_frame_images": ["first_frame", "last_frame"], + "generate_audio": true, + "allowed_passthrough_parameters": ["google-vertex.output_config"] + }], + "total": 1, "limit": 100, "offset": 0 + } + }))) + .mount(&server) + .await; + + let response: OpenRouterMediaModelsResponse = TinyHumansClient::new(server.uri()) + .agent_integrations() + .openrouter_video_models(&[]) + .await + .unwrap(); + let model = &response.data[0]; + assert_eq!( + model.supported_resolutions.as_deref(), + Some(&["720p".to_string()][..]) + ); + assert_eq!(model.supported_durations.as_deref(), Some(&[5, 8][..])); + assert_eq!(model.generate_audio, Some(true)); + assert_eq!(model.supported_sizes, None); +} + +#[tokio::test] +async fn polled_video_job_carries_unsigned_urls_and_usage() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/agent-integrations/openrouter/videos/job-abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": true, + "data": { + "id": "job-abc", + "status": "completed", + "unsigned_urls": ["https://storage.example.com/video.mp4"], + "usage": {"cost": 0.5} + } + }))) + .mount(&server) + .await; + + let job = TinyHumansClient::new(server.uri()) + .agent_integrations() + .get_openrouter_video("job-abc") + .await + .unwrap(); + assert_eq!( + job.unsigned_urls, + vec!["https://storage.example.com/video.mp4".to_string()] + ); + assert_eq!(job.usage.unwrap().cost, Some(0.5)); +} + +#[tokio::test] +async fn video_content_with_type_surfaces_the_upstream_content_type() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/agent-integrations/openrouter/videos/job-abc/content", + )) + .and(query_param("index", "1")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes([0_u8, 1, 2, 255]) + .insert_header("content-type", "video/mp4"), + ) + .mount(&server) + .await; + + let content = TinyHumansClient::new(server.uri()) + .agent_integrations() + .openrouter_video_content_with_type("job-abc", Some(1)) + .await + .unwrap(); + assert_eq!(content.bytes, vec![0, 1, 2, 255]); + assert_eq!(content.content_type.as_deref(), Some("video/mp4")); +} + #[tokio::test] async fn video_job_ids_are_path_encoded() { let server = MockServer::start().await;