omni api: status endpoint, api docs, etc (#1690)

This commit is contained in:
Brandon Thomas
2026-06-24 00:24:26 -04:00
committed by GitHub
parent 61a0880c80
commit 3b225a7c6d
13 changed files with 737 additions and 32 deletions
+232 -32
View File
@@ -1,5 +1,10 @@
# ArtCraft Omni API
> # 📚 Full API reference: **<https://storyteller-docs.netlify.app/>**
>
> **Every ArtCraft API endpoint is documented there.** This guide covers only the Omni API video
> endpoints — for everything else, start at the link above.
The **Omni API** is the API-key–authenticated surface for ArtCraft generation. It mirrors the
in-app "Omni Gen" endpoints but is designed for programmatic use: you authenticate with an API key
in the `Authorization` header instead of a browser session cookie.
@@ -25,43 +30,21 @@ All paths below are relative to the base URL (e.g. `POST /v1/omni_api/generate/v
The examples in this guide use the **development** base URL. To run against production, swap
`http://localhost:12345` for `https://api.storyteller.ai`.
Note: Our APIs are hosted on "`api.storyteller.ai`", not "`api.getartcraft.com`". This is not a
typo. Our Cloudflare CDN URLs are `cdn-2.fakeyou.com`.
---
## Getting an API key
API keys are created from a logged-in session. You only need to do this once; store the key
somewhere safe.
Create and manage API keys from the ArtCraft web app:
1. **Log in** to obtain a session (browser, or the `/v1/login` endpoint).
2. **Create a key** by calling `POST /v1/api_keys/create` with your session:
1. Go to **<https://app.getartcraft.com/>**
2. Click **Account → Settings → API Keys** (your account must have API keys enabled from our staff)
3. Create a key and copy it. The secret is shown **once** — store it somewhere safe.
```bash
curl -s -c /tmp/cookies.txt -X POST http://localhost:12345/v1/login \
-H "Content-Type: application/json" \
-d '{"username_or_email":"YOUR_USERNAME","password":"YOUR_PASSWORD"}'
curl -s -b /tmp/cookies.txt -X POST http://localhost:12345/v1/api_keys/create \
-H "Content-Type: application/json" \
-d '{"name":"my-integration","maybe_description":"server-to-server video gen"}'
```
The response returns the secret **once** — it can never be retrieved again:
```json
{
"success": true,
"api_key_token": "api_key_xxxxxxxxxxxxxxxxxxxxxxxxx",
"api_key": "artcraft_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
- `api_key` — the **secret**. Store it now; treat it like a password. This is the value you put
in the `Authorization` header.
- `api_key_token` — a non-secret handle (prefix `api_key_`) used to manage the key later
(get / update / delete). Safe to log and store.
> **403 Forbidden when creating a key?** API-key creation is gated per account. Contact the
> Storyteller team to enable API access for your account.
> **Don't see the API Keys section?** API access is gated per account. Contact the Artcraft team
> to enable it.
### What an API key looks like
@@ -69,7 +52,7 @@ somewhere safe.
- Followed by 40 random lowercase Crockford-base32 characters (`0-9 a-z`, excluding `i l o u`)
- Total length: **53 characters**, e.g. `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t8y0p2s4f6h8j0k2m4n6`
Never commit a key to source control or paste it into logs.
Treat the key like a password: never commit it to source control or paste it into logs.
---
@@ -412,6 +395,223 @@ console.log(resp.status, await resp.json());
---
## Checking job status
Generation is asynchronous. The generate call returns an `inference_job_token`; poll the job-status
endpoint until the job reaches a terminal state, then read the result URL.
### Endpoints
- **Single job:** `GET /v1/omni_api/job_status/job/{token}`
- **Batch (many jobs):** `GET /v1/omni_api/job_status/batch?tokens=jinf_aaa&tokens=jinf_bbb`
Both require the same `Authorization: Bearer …` header (no cookies).
### Response shape
```json
{
"success": true,
"state": {
"job_token": "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_xxxxxxxxxxxxxxxxxxxxxxxxx",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "pending",
"maybe_first_started_at": null,
"maybe_failure_category": null,
"progress_percentage": 0
},
"maybe_result": null,
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:06:14Z"
}
}
```
- `state.status.status` — the job state. Keep polling while it's `pending`, `started`, or
`attempt_failed`. **Terminal** states are `complete_success`, `complete_failure`, `dead`,
`cancelled_by_user`, and `cancelled_by_system`.
- `state.status.progress_percentage` — an integer 0–100.
- `state.maybe_result` — `null` until the job succeeds. On `complete_success` it contains the
finished media; the playable/downloadable URL is **`state.maybe_result.media_links.cdn_url`**.
For video, `media_links.maybe_video_previews` also carries still/animated preview URLs.
The batch endpoint returns `{ "success": true, "job_states": [ … ] }` where each entry is the same
`state` payload as above.
### Where the result video / image lives
When `state.status.status` is `complete_success`, the finished media is under `state.maybe_result`:
- **`state.maybe_result.media_links.cdn_url`** — the primary, playable/downloadable URL of the
finished asset. **This is the field you want.** For a video job it's the `.mp4`; for an image job
it's the image file (`.jpg` / `.png` / etc.).
- `state.maybe_result.entity_token` — the `m_…` media-file token for the result.
- `state.maybe_result.media_links.maybe_video_previews` — **video jobs only**. Contains a `still`
(poster `.jpg`) and an `animated` (`.gif`) preview URL, plus `{WIDTH}` thumbnail templates. This
is `null` for image jobs.
- `state.maybe_result.media_links.maybe_thumbnail_template` — **image jobs only**. A URL template;
replace `{WIDTH}` with a pixel width to fetch a resized thumbnail. `null` for video jobs.
**Example — completed video job:**
```json
{
"success": true,
"state": {
"job_token": "jinf_9fcn2qv4dv3vvbrrxbnmfgen06v",
"request": {
"inference_category": "video_generation",
"maybe_prompt_token": "prompt_q53tp4rh97qh8q159p21tazzt",
"maybe_model_type": "seedance_2p0",
"maybe_model_token": null
},
"status": {
"status": "complete_success",
"maybe_first_started_at": "2026-06-24T04:07:11Z",
"maybe_failure_category": null,
"progress_percentage": 100
},
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_ss682134tx19yhqs5y0nbnd0bd59a6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4",
"maybe_thumbnail_template": null,
"maybe_video_previews": {
"still": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.jpg",
"animated": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb.gif",
"still_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.jpg",
"animated_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4-thumb-{WIDTH}.gif"
}
},
"maybe_successfully_completed_at": "2026-06-24T04:17:30Z"
},
"created_at": "2026-06-24T04:06:14Z",
"updated_at": "2026-06-24T04:17:30Z"
}
}
```
→ The video is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/8/p/n/0/4/8pn04v7q3vfhf60wy173f6awcwkre1qj/artcraft_8pn04v7q3vfhf60wy173f6awcwkre1qj.mp4`
(example, not a live URL).
**Example — completed image job** (from `POST /v1/omni_api/generate/image`):
```json
{
"maybe_result": {
"entity_type": "media_file",
"entity_token": "m_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6",
"media_links": {
"cdn_url": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg",
"maybe_thumbnail_template": "https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg-thumb-{WIDTH}.jpg",
"maybe_video_previews": null
},
"maybe_successfully_completed_at": "2026-06-24T04:09:02Z"
}
}
```
→ The image is at
`https://pub-c8a4a5bdbdb048f286b77bdf9f786ff2.r2.dev/media/3/k/q/9/w/3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6/artcraft_3kq9w0xv2hs5n8m4d6r1t8y0p2s4f6.jpg`
(example, not a live URL).
### Poll until complete
#### bash
```bash
JOB_TOKEN="jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
while :; do
resp=$(curl -s "http://localhost:12345/v1/omni_api/job_status/job/$JOB_TOKEN" \
-H "Authorization: Bearer YOUR_API_KEY")
status=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['status']['status'])")
echo "status=$status"
case "$status" in
complete_success)
echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['state']['maybe_result']['media_links']['cdn_url'])"
break ;;
complete_failure|dead|cancelled_by_user|cancelled_by_system)
echo "job ended without a result: $status"; break ;;
esac
sleep 5
done
```
#### python
```python
import time
import requests
BASE_URL = "http://localhost:12345" # production: https://api.storyteller.ai
API_KEY = "YOUR_API_KEY"
JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx"
TERMINAL_FAILURES = {"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system"}
while True:
resp = requests.get(
f"{BASE_URL}/v1/omni_api/job_status/job/{JOB_TOKEN}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
state = resp.json()["state"]
status = state["status"]["status"]
print("status:", status, state["status"]["progress_percentage"], "%")
if status == "complete_success":
print("result URL:", state["maybe_result"]["media_links"]["cdn_url"])
break
if status in TERMINAL_FAILURES:
print("job ended without a result:", status)
break
time.sleep(5)
```
#### javascript
```javascript
const BASE_URL = "http://localhost:12345"; // production: https://api.storyteller.ai
const API_KEY = "YOUR_API_KEY";
const JOB_TOKEN = "jinf_xxxxxxxxxxxxxxxxxxxxxxxxx";
const TERMINAL_FAILURES = new Set([
"complete_failure", "dead", "cancelled_by_user", "cancelled_by_system",
]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
while (true) {
const resp = await fetch(`${BASE_URL}/v1/omni_api/job_status/job/${JOB_TOKEN}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
const { state } = await resp.json();
const status = state.status.status;
console.log("status:", status, state.status.progress_percentage, "%");
if (status === "complete_success") {
console.log("result URL:", state.maybe_result.media_links.cdn_url);
break;
}
if (TERMINAL_FAILURES.has(status)) {
console.log("job ended without a result:", status);
break;
}
await sleep(5000);
}
```
---
## Notes & troubleshooting
- **Always send a fresh `idempotency_token`** (UUID). Reusing one is rejected as a duplicate.
@@ -0,0 +1,3 @@
pub mod omni_api_batch_get_job_status;
pub mod omni_api_get_job_status;
pub mod omni_api_job_status_payload;
@@ -0,0 +1,25 @@
use std::collections::HashSet;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Query params for `GET /v1/omni_api/job_status/batch`.
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct OmniApiBatchGetJobStatusQueryParams {
/// A grab bag of job tokens. Each token family generally has its own prefix,
/// so a flat set is sufficient.
///
/// NB: handlers parse this with `actix_web_lab`'s `Query<T>` because the
/// default actix-web `Query<T>` doesn't decode repeated/sequence params yet.
/// See https://github.com/actix/actix-web/issues/1301
pub tokens: HashSet<String>,
}
/// Success body for `GET /v1/omni_api/job_status/batch`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiBatchGetJobStatusSuccessResponse {
pub success: bool,
pub job_states: Vec<OmniApiJobStatusPayload>,
}
@@ -0,0 +1,19 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::omni_api::job_status::omni_api_job_status_payload::OmniApiJobStatusPayload;
/// Path params for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusPathInfo {
pub token: InferenceJobToken,
}
/// Success body for `GET /v1/omni_api/job_status/job/{token}`.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiGetJobStatusSuccessResponse {
pub success: bool,
pub state: OmniApiJobStatusPayload,
}
@@ -0,0 +1,69 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use utoipa::ToSchema;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::job_status_plus::JobStatusPlus;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::prompts::PromptToken;
use crate::common::responses::media_links::MediaLinks;
/// Full status payload for a single inference job.
///
/// Shared by both the single (`/v1/omni_api/job_status/job/{token}`) and batch
/// (`/v1/omni_api/job_status/batch`) Omni API status endpoints. Forked from the
/// cookie-authenticated `/v1/jobs/*` response types so the Omni API surface can
/// evolve independently.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusPayload {
pub job_token: InferenceJobToken,
pub request: OmniApiJobRequestDetails,
pub status: OmniApiJobStatusDetails,
pub maybe_result: Option<OmniApiJobResultDetails>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Details about what the user requested for generation.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobRequestDetails {
pub inference_category: InferenceCategory,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
}
/// Details about the ongoing job status.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobStatusDetails {
/// Primary status from the database (a state machine).
pub status: JobStatusPlus,
pub maybe_first_started_at: Option<DateTime<Utc>>,
/// An enum the frontend can use to display localized/I18N error
/// messages. These pertain to both transient and permanent failures.
pub maybe_failure_category: Option<FrontendFailureCategory>,
/// This is an integer number between 0 and 100 (both inclusive) that
/// reports the completeness.
pub progress_percentage: u8,
}
/// Details about the completed result (if any).
#[derive(Serialize, Deserialize, ToSchema)]
pub struct OmniApiJobResultDetails {
pub entity_type: String,
pub entity_token: String,
/// Rich CDN links to the media, including thumbnails, previews, and more.
pub media_links: MediaLinks,
pub maybe_successfully_completed_at: Option<DateTime<Utc>>,
}
@@ -1,4 +1,5 @@
pub mod generate_requests;
pub mod job_status;
pub mod omni_upload_audio;
pub mod omni_upload_image;
pub mod omni_upload_video;
@@ -349,6 +349,9 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::*;
use artcraft_api_defs::omni_gen::models::omni_gen_image_models::*;
use artcraft_api_defs::omni_gen::models::omni_gen_video_models::*;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::*;
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::*;
use artcraft_api_defs::omni_api::omni_upload_audio::*;
use artcraft_api_defs::omni_api::omni_upload_image::*;
use artcraft_api_defs::omni_api::omni_upload_video::*;
@@ -573,6 +576,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler,
crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler,
crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler,
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
@@ -826,7 +831,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
GetCharacterResponse,
ListCharactersEntry,
ListCharactersResponse,
OmniApiBatchGetJobStatusSuccessResponse,
OmniApiGetJobStatusSuccessResponse,
OmniApiImageGenerateRequest,
OmniApiJobRequestDetails,
OmniApiJobResultDetails,
OmniApiJobStatusDetails,
OmniApiJobStatusPayload,
OmniApiVideoGenerateRequest,
OmniGenImageCostAndGenerateRequest,
OmniGenImageCostResponse,
@@ -0,0 +1,127 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use actix_web_lab::extract::Query;
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_batch_get_job_status::{OmniApiBatchGetJobStatusQueryParams, OmniApiBatchGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::batch_get_inference_job_status::batch_get_inference_job_status;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use redis_common::redis_keys::RedisKeys;
use server_environment::ServerEnvironment;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get job statuses for a batch of multiple job tokens (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/batch` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/batch",
responses(
(status = 200, body = OmniApiBatchGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500),
),
params(
OmniApiBatchGetJobStatusQueryParams
)
)]
pub async fn omni_api_batch_get_job_status_handler(
http_request: HttpRequest,
query: Query<OmniApiBatchGetJobStatusQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiBatchGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
let tokens = query.tokens.iter()
.map(|token| token.trim())
// NB: A bunch of Python clients use our API and can fail in this manner.
.filter(|token| !token.is_empty() && *token != "None")
.map(|token| InferenceJobToken::new_from_str(token))
.collect::<Vec<_>>();
if tokens.is_empty() {
// NB: MediaDomain doesn't matter since it's an empty list.
return Ok(records_to_response(
Vec::new(),
server_state.server_environment,
MediaDomain::Storyteller,
));
}
let records = batch_get_inference_job_status(
&tokens,
&server_state.mysql_pool,
).await
.map_err(|err| {
warn!("Batch job query error: {:?}", err);
CommonWebError::from_anyhow_error(err)
})?;
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
// TODO(bt,2024-04-22): Look up the extra redis statuses per item.
let keepalive_keys = records.iter()
.filter(|record| record.is_keepalive_required)
.map(|record| RedisKeys::generic_inference_keepalive(record.job_token.as_str()))
.collect::<Vec<_>>();
for key in keepalive_keys.iter() {
// TODO(bt,2024-04-22): There is no msetex. We'll need to run a Redis pipeline here.
let _: Option<String> = match redis.set_ex(key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
Ok(records_to_response(records, server_state.server_environment, media_domain))
}
fn records_to_response(
records: Vec<GenericInferenceJobStatus>,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> Json<OmniApiBatchGetJobStatusSuccessResponse> {
Json(OmniApiBatchGetJobStatusSuccessResponse {
success: true,
job_states: records.into_iter()
.map(|record| record_to_payload(record, server_environment, media_domain))
.collect::<Vec<_>>(),
})
}
@@ -0,0 +1,107 @@
use std::sync::Arc;
use actix_web::web::{Json, Path};
use actix_web::{web, HttpRequest};
use log::{error, warn};
use redis::Commands;
use artcraft_api_defs::omni_api::job_status::omni_api_get_job_status::{OmniApiGetJobStatusPathInfo, OmniApiGetJobStatusSuccessResponse};
use mysql_queries::queries::generic_inference::web::get_inference_job_status::get_inference_job_status;
use redis_common::redis_keys::RedisKeys;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
use crate::http_server::endpoints::omni_api::job_status::record_to_payload::record_to_payload;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::state::server_state::ServerState;
/// For certain jobs or job classes (eg. non-premium), we kill the jobs if the user hasn't
/// maintained a keepalive. This prevents wasted work when users who are unlikely to return
/// navigate away. Premium users have accounts and can always return to the site, so they
/// typically do not require keepalive.
const JOB_KEEPALIVE_TTL_SECONDS: u64 = 60 * 3;
/// Get the status for a single job (Omni API, API-key authenticated).
///
/// Mirrors `/v1/jobs/job/{token}` but reads the caller's identity from the
/// `Authorization` header API key instead of a session cookie.
#[utoipa::path(
get,
tag = "Omni API",
path = "/v1/omni_api/job_status/job/{token}",
params(
("path" = OmniApiGetJobStatusPathInfo, description = "Path params for Request")
),
responses(
(status = 200, body = OmniApiGetJobStatusSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 404),
(status = 500),
),
)]
pub async fn omni_api_get_job_status_handler(
http_request: HttpRequest,
path: Path<OmniApiGetJobStatusPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniApiGetJobStatusSuccessResponse>, CommonWebError> {
// ==================== API KEY USER ==================== //
// API-key authentication (Authorization header) instead of a session cookie. Never cached, and a
// banned owner is rejected inside `require_api_key_user`. Drop the connection before the job
// lookups below so we don't hold a pool slot idle.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
require_api_key_user(&http_request, &mut *mysql_connection).await?;
drop(mysql_connection);
if path.token.as_str().trim() == "None" {
// NB: A bunch of Python clients use our API and can fail in this manner.
return Err(CommonWebError::NotFound);
}
let maybe_status = get_inference_job_status(
&path.token,
&server_state.mysql_pool,
).await;
let record = match maybe_status {
Ok(Some(record)) => record,
Ok(None) => return Err(CommonWebError::NotFound),
Err(err) => {
warn!("Job query error for token {}: {:?}", path.token.as_str(), err);
return Err(CommonWebError::from_anyhow_error(err));
}
};
let mut redis = server_state.redis_pool
.get()
.map_err(|e| {
warn!("Redis pool error: {:?}", e);
CommonWebError::from_error(e)
})?;
if record.is_keepalive_required {
// TODO(bt,2023-05-21): Make async.
let extra_status_key = RedisKeys::generic_inference_extra_status_info(path.token.as_str());
let _: Option<String> = match redis.set_ex(&extra_status_key, "1", JOB_KEEPALIVE_TTL_SECONDS) {
Ok(Some(status)) => Some(status),
Ok(None) => None,
Err(e) => {
error!("redis error setting job keepalive: {:?}", e);
None // Fail open (which in this case is bad! it will kill jobs if cluster has many jobs / is slow!)
}
};
}
let media_domain = get_media_domain(&http_request);
let state = record_to_payload(
record,
server_state.server_environment,
media_domain,
);
Ok(Json(OmniApiGetJobStatusSuccessResponse {
success: true,
state,
}))
}
@@ -0,0 +1,3 @@
pub mod batch_get_job_status_handler;
pub mod get_job_status_handler;
pub mod record_to_payload;
@@ -0,0 +1,127 @@
use artcraft_api_defs::omni_api::job_status::omni_api_job_status_payload::{
OmniApiJobRequestDetails, OmniApiJobResultDetails, OmniApiJobStatusDetails, OmniApiJobStatusPayload,
};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use bucket_paths::legacy::typified_paths::public::voice_conversion_results::bucket_file_path::VoiceConversionResultOriginalFilePath;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use mysql_queries::queries::generic_inference::web::job_status::GenericInferenceJobStatus;
use server_environment::ServerEnvironment;
use crate::http_server::common_responses::media::media_domain::MediaDomain;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::inference_job::utils::estimates::estimate_job_progress::estimate_job_progress;
use crate::http_server::endpoints::inference_job::utils::extractors::extract_polymorphic_inference_args::extract_polymorphic_inference_args;
use crate::http_server::web_utils::filter_model_name::maybe_filter_model_name;
/// Convert a DB job-status record into the Omni API response payload.
///
/// This is the same mapping the cookie-authenticated `/v1/jobs/*` handlers use;
/// it's shared by both the single and batch Omni API status handlers.
#[allow(deprecated)] // We match deprecated `InferenceCategory` variants in the result-path mapping.
pub fn record_to_payload(
record: GenericInferenceJobStatus,
server_environment: ServerEnvironment,
media_domain: MediaDomain,
) -> OmniApiJobStatusPayload {
let inference_category = record.request_details.inference_category;
// NB: Fail open. We don't want to fail the request if we can't extract the args.
let maybe_polymorphic_args = extract_polymorphic_inference_args(&record)
.ok()
.flatten();
let progress_percentage = estimate_job_progress(&record, maybe_polymorphic_args.as_ref());
OmniApiJobStatusPayload {
job_token: record.job_token,
request: OmniApiJobRequestDetails {
inference_category: record.request_details.inference_category,
maybe_prompt_token: record.request_details.maybe_prompt_token,
maybe_model_type: maybe_filter_model_name(record.request_details.maybe_model_type.as_deref()),
maybe_model_token: record.request_details.maybe_model_token,
},
status: OmniApiJobStatusDetails {
status: record.status,
maybe_first_started_at: record.maybe_first_started_at,
maybe_failure_category: record.maybe_frontend_failure_category,
progress_percentage,
},
maybe_result: record.maybe_result_details.map(|result_details| {
// NB: Be careful here, because this varies based on the type of inference result.
let public_bucket_media_path = match inference_category {
// NB: TTS has to be special cased due to legacy behavior
InferenceCategory::TextToSpeech
| InferenceCategory::F5TTS => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating TTS to media_files.
// Zero shot TTS uses media files.
// Legacy TT2 uses old pathing.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: TTS results receive the legacy treatment where their table only reports the full bucket path
result_details.public_bucket_location_or_hash
}
}
}
// NB: Voice conversion has to be special cased due to legacy behavior
InferenceCategory::VoiceConversion | InferenceCategory::SeedVc => {
match result_details.entity_type.as_str() {
"media_file" => {
// NB: We're migrating voice conversion to media_files.
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
_ => {
// NB: This is the old voice conversion result pathing.
VoiceConversionResultOriginalFilePath::from_object_hash(&result_details.public_bucket_location_or_hash)
.get_full_object_path_str()
.to_string()
}
}
}
// Unsupported media files.
InferenceCategory::FormatConversion |
InferenceCategory::ConvertBvhToWorkflow => {
"".to_string()
}
// Deprecated
InferenceCategory::DeprecatedField => {
"".to_string() // TODO(bt,2024-07-16): Read job type instead
}
// The blessed path that modern media files use.
_ => {
MediaFileBucketPath::from_object_hash(
&result_details.public_bucket_location_or_hash,
result_details.maybe_media_file_public_bucket_prefix.as_deref(),
result_details.maybe_media_file_public_bucket_extension.as_deref())
.get_full_object_path_str()
.to_string()
}
};
OmniApiJobResultDetails {
entity_type: result_details.entity_type,
entity_token: result_details.entity_token,
media_links: MediaLinksBuilder::from_rooted_path_and_env(
media_domain,
server_environment,
&public_bucket_media_path,
),
maybe_successfully_completed_at: result_details.maybe_successfully_completed_at,
}
}),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
@@ -1,3 +1,4 @@
pub mod generate;
pub mod job_status;
pub mod shared_utils;
pub mod upload;
@@ -5,6 +5,8 @@ use actix_web::{web, App, Error, HttpResponse};
use crate::http_server::endpoints::omni_api::generate::image::omni_api_image_generate_handler::omni_api_image_generate_handler;
use crate::http_server::endpoints::omni_api::generate::video::omni_api_video_generate_handler::omni_api_video_generate_handler;
use crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler;
use crate::http_server::endpoints::omni_api::job_status::get_job_status_handler::omni_api_get_job_status_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_audio_media_file_handler::omni_upload_audio_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_image_media_file_handler::omni_upload_image_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
@@ -49,5 +51,15 @@ where
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/job_status")
.service(web::resource("/job/{token}")
.route(web::get().to(omni_api_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/batch")
.route(web::get().to(omni_api_batch_get_job_status_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
)
}