# Java Source: https://docs.sunra.ai/client-libraries/java [![Maven Central Version](https://img.shields.io/maven-central/v/ai.sunra.client/sunra-client)](https://search.maven.org/artifact/ai.sunra.client/sunra-client) ### Introduction The client library for Java offers an easy-to-use interface to interact with Sunra's services. ### Installation To integrate the client into your project, add the dependency using Maven or Gradle: ```kotlin Gradle theme={null} implementation("ai.sunra.client:sunra-client:0.1.5") ``` ```xml Maven theme={null} ai.sunra.client sunra-client 0.1.5 ``` ### Features #### Calling Endpoints Sunra manages endpoint requests through a queue system, ensuring reliability and scalability. Use the `subscribe` method to submit a request and await the result. **Example:** ```java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "a dog running in the park"); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); // save the subscribe request to a variable and print it Output response = client.subscribe("black-forest-labs/flux-kontext-pro/text-to-image", options); System.out.println("Completed!"); System.out.println(response.getData()); ``` #### Queue Management Manage your requests with these methods: ##### Submitting a Request Submit a request and retrieve the `request_id` for later use. **Example:** ```java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "a dog running in the park"); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); // save the subscribe request to a variable and print it var request = client.queue().submit("black-forest-labs/flux-kontext-pro/text-to-image", options); ``` ##### Checking Request Status Retrieve the status of a request. **Example:** ```java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); var job = client.queue().status(QueueStatusOptions.withRequestId("pd_eTYYuw4EqYLzRBHgnAMHA8zH")); ``` ##### Retrieving Request Results Fetch the result of a completed request. **Example:** ```java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); var result = client.queue().result(QueueStatusOptions.withRequestId("pd_eTYYuw4EqYLzRBHgnAMHA8zH")); ``` #### File Uploads > [!NOTE] > > Not implemented yet. > [!NOTE] > > Maximum file size: 100MB ### Support Join our community for help or discussions: * **Discord Community**: [Join us](https://discord.gg/897qCzvCcU) * **GitHub Repository**: [Visit here](https://github.com/sunra-ai/sunra-clients) We're here to assist you! # JavaScript / TypeScript Source: https://docs.sunra.ai/client-libraries/javascript [![@sunra/client npm package](https://img.shields.io/npm/v/@sunra/client?color=%237527D7\&label=JavaScript\&style=flat-square)](https://www.npmjs.com/package/@sunra/client) ### Introduction The client library for JavaScript/TypeScript offers an easy-to-use interface to interact with Sunra's services. ### Installation To integrate the client into your project, install it using npm: ```bash npm theme={null} npm install @sunra/client ``` ```bash pnpm theme={null} pnpm add @sunra/client ``` ```bash yarn theme={null} yarn add @sunra/client ``` ### Features #### Calling Endpoints Sunra manages endpoint requests through a queue system, ensuring reliability and scalability. Use the `subscribe` method to submit a request and await the result. **Example:** ```javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("black-forest-labs/flux-kontext-pro/text-to-image", { input: { prompt: "A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.", "aspect_ratio": "16:9", "output_format": "jpeg", }, logs: true, onQueueUpdate: (update) => { if (update.status === "IN_PROGRESS") { console.log(update.logs) } }, }); console.log(result.data); console.log(result.requestId); ``` #### Queue Management Manage your requests with these methods: ##### Submitting a Request Submit a request and retrieve the `request_id` for later use. **Example:** ```javascript theme={null} import { sunra } from "@sunra/client"; const { request_id } = await sunra.queue.submit("black-forest-labs/flux-kontext-max", { input: { prompt: "A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.", "aspect_ratio": "16:9", "output_format": "jpeg", }, }); ``` ##### Checking Request Status Retrieve the status of a request. **Example:** ```javascript theme={null} import { sunra } from "@sunra/client"; const status = await sunra.queue.status({ requestId: "pd_eTYYuw4EqYLzRBHgnAMHA8zH", logs: true, }); ``` ##### Retrieving Request Results Fetch the result of a completed request. **Example:** ```javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.queue.result({ requestId: "pd_eTYYuw4EqYLzRBHgnAMHA8zH", }); console.log(result.data); console.log(result.requestId); ``` #### Storage The `storage` API allows you to upload files and receive a URL, which can then be used in your model endpoint requests. This is particularly useful for models that require file inputs, such as image-to-video or speech-to-text. > [!NOTE] > > Maximum file size: 100MB ##### Uploading Files in the Browser You can allow users to upload files directly from their browser. The following example shows how to use an `` element to select a file and upload it. ```javascript upload.js theme={null} import { sunra } from "@sunra/client"; const fileInput = document.getElementById('file-input'); fileInput.addEventListener('change', async (event) => { const file = event.target.files[0]; if (file) { try { const url = await sunra.storage.upload(file); console.log('File uploaded successfully:', url); // Now you can use this URL with a model endpoint } catch (error) { console.error('Upload failed:', error); } } }); ``` ```html index.html theme={null} ``` ##### Uploading Files in Node.js On the server-side with Node.js, you can read files from the local filesystem and upload them. ```javascript theme={null} import { sunra } from "@sunra/client"; import { readFile } from "node:fs/promises"; import { basename } from "node:path"; async function uploadLocalFile(filePath) { try { const buffer = await readFile(filePath); // The client needs a File object, which we can create from a buffer const file = new File([buffer], basename(filePath)); const url = await sunra.storage.upload(file); console.log('File uploaded successfully:', url); return url; } catch (error) { console.error('Upload failed:', error); } } uploadLocalFile("./path/to/your/image.png"); ``` #### Automatic Uploads with Model Endpoints The JavaScript SDK can automatically handle file uploads for you. When you pass a `File` object, `Blob`, or a base64 data URI to a model endpoint's input, the SDK will first upload it to storage and then use the resulting URL in the request. This simplifies the process as you don't need to perform a separate upload step. ```javascript theme={null} import { sunra } from "@sunra/client"; // A File object from a browser file input will be automatically uploaded const fileInput = document.getElementById('file-input'); const file = fileInput.files[0]; const result = await sunra.subscribe("some-model-that-takes-images", { input: { image: file, // SDK automatically uploads this file prompt: "A prompt describing what to do with the image" } }); // A base64 data URI will also be automatically uploaded const base64Image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."; const result2 = await sunra.subscribe("some-model-that-takes-images", { input: { image: base64Image, // SDK automatically converts this to a Blob and uploads it prompt: "A prompt describing what to do with the image" } }); ``` ### Support Join our community for help or discussions: * **Discord Community**: [Join us](https://discord.gg/897qCzvCcU) * **GitHub Repository**: [Visit here](https://github.com/sunra-ai/sunra-clients) We're here to assist you! # Python Source: https://docs.sunra.ai/client-libraries/python [![PyPI - Version](https://img.shields.io/pypi/v/sunra_client)](https://pypi.org/project/sunra_client) ### Introduction The client library for Python offers an easy-to-use interface to interact with Sunra's services. ### Installation To integrate the client into your project, install it using pip: ```bash theme={null} pip install sunra-client ``` ### Features #### Calling Endpoints Sunra manages endpoint requests through a queue system, ensuring reliability and scalability. Use the `subscribe` method to submit a request and await the result. **Example:** ```python theme={null} import sunra_client result = sunra_client.subscribe( "black-forest-labs/flux-kontext-pro/text-to-image", arguments={ "prompt": 'A Studio Ghibli-inspired seaside town with colorful houses, laundry flapping, and cats sleeping on windowsills.', "width": 1024, "height": 768, "output_format": 'jpeg' }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` #### Queue Management Manage your requests with these methods: ##### Submitting a Request Submit a request and retrieve the `request_id` for later use. **Example:** ```python theme={null} import sunra_client handler = sunra_client.submit( "black-forest-labs/flux-kontext-pro/text-to-image", arguments={ "prompt": 'A Studio Ghibli-inspired seaside town with colorful houses, laundry flapping, and cats sleeping on windowsills.', "width": 1024, "height": 768, "output_format": 'jpeg' }, webhook_url="https://optional.webhook.url/for/results", ) request_id = handler.request_id ``` ##### Checking Request Status Retrieve the status of a request. **Example:** ```python theme={null} import sunra_client status = sunra_client.status(request_id, with_logs=True) ``` ##### Retrieving Request Results Fetch the result of a completed request. **Example:** ```python theme={null} import sunra_client result = sunra_client.result(request_id) ``` #### File Uploads Upload files to obtain URLs for use in asynchronous requests. This is essential for models that process files, such as image-to-video or speech-to-text converters. > [!NOTE] > > Maximum file size: 100MB ##### Uploading a Local File You can easily upload a file from your local filesystem. This is useful for scripts or applications that process local data. ```python theme={null} import sunra_client # Initialize the synchronous client client = sunra_client.SyncClient() try: # Upload a file from a given path file_url = client.upload_file(path="path/to/your/image.png") print(f"File uploaded successfully: {file_url}") # This URL can now be used with a model endpoint except FileNotFoundError: print("Error: The file was not found at the specified path.") except Exception as e: print(f"An error occurred: {e}") ``` ##### Uploading In-Memory Data You can also upload data that is held in memory, such as the content of an image created with PIL (Pillow) or a file received in a web request. ```python PIL Image theme={null} import sunra_client from PIL import Image import io # Initialize the synchronous client client = sunra_client.SyncClient() # Create an image in memory, for example with Pillow image = Image.new("RGB", (600, 400), color = 'red') byte_arr = io.BytesIO() image.save(byte_arr, format='PNG') image_bytes = byte_arr.getvalue() # Upload the in-memory image data using the `upload` method image_url = client.upload( data=image_bytes, content_type="image/png" ) print(f"Image uploaded successfully: {image_url}") ``` ```python Raw Bytes theme={null} import sunra_client # Initialize the synchronous client client = sunra_client.SyncClient() # Some raw bytes you might have from reading a file or a web request raw_data = b"some file content here" # Upload the raw bytes data_url = client.upload( data=raw_data, content_type="application/octet-stream" ) print(f"Data uploaded successfully: {data_url}") ``` ### Support Join our community for help or discussions: * **Discord Community**: [Join us](https://discord.gg/897qCzvCcU) * **GitHub Repository**: [Visit here](https://github.com/sunra-ai/sunra-clients) We're here to assist you! # Request New Client Source: https://docs.sunra.ai/client-libraries/request-new-client We have support via official client libraries for the following languages: * [JavaScript](/client-libraries/javascript) * [Python](/client-libraries/python) * [Java / Kotlin](/client-libraries/java) Don't see your language? We are working on adding support for more languages. [Reach out](https://discord.gg/897qCzvCcU) and let us know which language you would like to see next. In the meantime, you can use the [REST API directly](/multimodal/queue). # Introduction Source: https://docs.sunra.ai/index Sunra.ai is an all-in-one model platform built for developers, providing high-performance AI inference across multimodal and LLM models through production-ready APIs. > [!NOTE] > > **July 30, 2026 — Developer site moved to platform.sunra.ai** > > The Sunra developer site (dashboard, model catalog, playground, API keys, billing) is now at [platform.sunra.ai](https://platform.sunra.ai). Old `sunra.ai` links redirect permanently. > > **No API change.** `api.sunra.ai`, `api-llm.sunra.ai` and `oapi.sunra.ai` are unchanged, as are the client libraries and your API keys. No action required. > > Sessions are per hostname, so you will be signed out once on the new host and need to sign in again. Account, keys, credits and usage history are unaffected. ![Hero Light](https://mintcdn.com/sunraai/U2o8uzmv75no70tM/images/hero-light.png?fit=max\&auto=format\&n=U2o8uzmv75no70tM\&q=85\&s=f570f562a7d3d31ea82f275d2bb4d9ff) ## All-in-One Model Platform Sunra.ai is an **all-in-one model (AOM) platform** that gives developers unified access to both multimodal AI models and large language models through a single API. Whether you need image generation, video creation, speech-to-text, or conversational AI — Sunra provides production-ready inference for all of them. ### Multimodal Models Run state-of-the-art diffusion and media models for generative tasks: * **Image generation** — text-to-image with models like Flux and Nano Banana * **Video generation** — text-to-video and image-to-video with models like Kling and Veo * **Speech-to-text** — transcription and audio processing ### Large Language Models Access leading LLMs through OpenAI-compatible and Anthropic-compatible APIs: * **Chat Completions** (`/v1/chat/completions`) — OpenAI Chat format * **Anthropic Messages** (`/v1/messages`) — native Claude support with extended thinking * **Responses** (`/v1/responses`) — OpenAI Responses format with built-in tools ### Platform Features * **Unified API** — one API key, one platform for all models * **Production-ready infrastructure** with queue management and real-time monitoring * **Multi-language SDKs** for JavaScript, Python, Java, and Kotlin * **OpenAI & Anthropic compatible** — drop-in replacement for existing integrations # Create a chat completion Source: https://docs.sunra.ai/llm/chat POST https://api-llm.sunra.ai/v1/chat/completions Sends a request for a model response for the given chat conversation. Supports both streaming and non-streaming modes, text, images, audio, video, files, function calling, reasoning, and structured outputs. Compatible with the OpenAI Chat Completions API format. ## Authentication **Authorization** `string` *required* Bearer token. Use your API key as the bearer token in the Authorization header. Format: `Bearer ` ## Request This endpoint expects an object. **messages** `object[]` *required* List of messages for the conversation. Each message has a `role` and `content`. **message types** **SystemMessage** **role** `string` *required* Value: `system`. **content** `string | object[]` *required* The system message content. Can be a string or array of text content parts. **name** `string` Optional name for the system message. **UserMessage** **role** `string` *required* Value: `user`. **content** `string | object[]` *required* The user message content. Can be a string or array of content parts. **content part types** **text** **type** `string` *required* Value: `text`. **text** `string` *required* The text content. **cache\_control** `object` Cache control for this content part. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Cache TTL. Supported values: `5m`, `1h`. **image\_url** **type** `string` *required* Value: `image_url`. **image\_url** `object` *required* Image URL object. **properties** **url** `string` *required* URL of the image. Supports `data:` URLs for base64 encoded images. **detail** `string` Image detail level for vision models. Supported values: `auto`, `low`, `high`. **input\_audio** **type** `string` *required* Value: `input_audio`. **input\_audio** `object` *required* Audio input data. **properties** **data** `string` *required* Base64 encoded audio data. **format** `string` *required* Audio format (e.g., `wav`, `mp3`, `flac`, `m4a`, `ogg`, `aiff`, `aac`). Supported formats vary by provider. **video\_url** **type** `string` *required* Value: `video_url`. **video\_url** `object` *required* Video input object. **properties** **url** `string` *required* URL of the video. Supports `data:` URLs for base64 encoded video. **file** **type** `string` *required* Value: `file`. **file** `object` *required* File content for document processing. **properties** **file\_data** `string` File content as base64 data URL or URL. **file\_id** `string` File ID for previously uploaded files. **filename** `string` Original filename. **name** `string` Optional name for the user. **DeveloperMessage** **role** `string` *required* Value: `developer`. **content** `string | object[]` *required* The developer message content. Can be a string or array of text content parts. **name** `string` Optional name for the developer message. **AssistantMessage** **role** `string` *required* Value: `assistant`. **content** `string | object[] | null` The assistant message content. Can be a string, array of content parts, or null (when tool\_calls are present). **name** `string` Optional name for the assistant. **tool\_calls** `object[]` Tool calls made by the assistant. **properties** **id** `string` *required* Tool call identifier. **type** `string` *required* Value: `function`. **function** `object` *required* The function called. **properties** **name** `string` *required* Function name. **arguments** `string` *required* Function arguments as JSON string. **refusal** `string | null` Refusal message if content was refused. **reasoning** `string | null` Reasoning output text. **reasoning\_details** `object[]` Detailed reasoning information for extended thinking models. **reasoning detail types** **summary** **type** `string` *required* Value: `reasoning.summary`. **summary** `string` *required* The reasoning summary text. **id** `string | null` Reasoning detail ID. **format** `string | null` Format. Supported values: `unknown`, `openai-responses-v1`, `azure-openai-responses-v1`, `xai-responses-v1`, `anthropic-claude-v1`, `google-gemini-v1`. **encrypted** **type** `string` *required* Value: `reasoning.encrypted`. **data** `string` *required* Encrypted reasoning data. **id** `string | null` Reasoning detail ID. **format** `string | null` Format identifier. **text** **type** `string` *required* Value: `reasoning.text`. **text** `string | null` The reasoning text content. **signature** `string | null` Signature for verification. **id** `string | null` Reasoning detail ID. **format** `string | null` Format identifier. **images** `object[]` Generated images from image generation models. Each item has `image_url.url`. **audio** `object` Audio output data. **properties** **id** `string` Audio output identifier. **data** `string` Base64 encoded audio data. **transcript** `string` Audio transcript. **expires\_at** `number` Audio expiration timestamp. **ToolResponseMessage** **role** `string` *required* Value: `tool`. **content** `string | object[]` *required* Tool response content. Can be a string or array of content parts (text, image, audio, video, file). **tool\_call\_id** `string` *required* ID of the assistant message tool call this message responds to. **model** `string` *required* The model to use for the completion. Browse available models at [sunra.ai/models](https://sunra.ai/models). **provider** `object` Optional provider routing preferences. Omit for automatic routing. See [Provider routing](/llm/provider-routing) for supported fields and provider discovery. **stream** `boolean` (default: false) If set to `true`, partial message deltas will be sent as server-sent events (SSE). Streams are subject to an idle timeout and a lifetime ceiling — see [Output limits and stream lifetime](/llm/limits#stream-lifetime). A non-streaming request is terminated at the edge if the response takes too long to arrive, so any request that may generate for more than \~90 seconds must set `stream: true` — see [the edge timeout](/llm/limits#non-streaming-requests-the-edge-timeout). **max\_completion\_tokens** `number | null` Maximum tokens in completion. Replaces `max_tokens` as the preferred parameter. On providers that do not honor this field the gateway translates it into `max_tokens`; when both are set, the smaller one applies. See [Output limits](/llm/limits). **max\_tokens** `number | null` Maximum tokens in completion. Deprecated — use `max_completion_tokens` instead. Note: some providers enforce a minimum of 16. A value above the model's own output ceiling is rejected with `400` rather than reduced. **temperature** `number | null` (default: 1) Sampling temperature between 0 and 2. Higher values like 0.8 make output more random, lower values like 0.2 make it more focused and deterministic. For OpenAI reasoning models (`gpt-5.x`, `gpt-6-*`) with reasoning enabled (`reasoning_effort` other than `none`), `temperature` and `top_p` are not supported upstream; the gateway drops them silently instead of returning `400`, so the request still succeeds. Set `reasoning_effort: "none"` if you need sampling control. **top\_p** `number | null` (default: 1) Nucleus sampling parameter (0-1). An alternative to temperature sampling where the model considers the tokens with top\_p probability mass. **frequency\_penalty** `number | null` (default: 0) Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim. **presence\_penalty** `number | null` (default: 0) Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. **stop** `string | string[]` Up to 4 sequences where the API will stop generating further tokens. **n** `integer` (default: 1) How many chat completion choices to generate for each input message. **logprobs** `boolean | null` (default: false) Whether to return log probabilities of the output tokens. **top\_logprobs** `number | null` An integer between 0 and 20 specifying the number of most likely tokens to return at each token position. `logprobs` must be set to `true` if this parameter is used. **logit\_bias** `object | null` Token logit bias adjustments. Modify the likelihood of specified tokens appearing in the completion. Maps token IDs to bias values from -100 to 100. **reasoning** `object` Configuration options for reasoning models. **properties** **effort** `string | null` Constrains effort on reasoning. Supported values: `xhigh`, `high`, `medium`, `low`, `minimal`, `none`. **summary** `string` Controls reasoning summary verbosity. Supported values: `auto`, `concise`, `detailed`. **thinking** `object` Cross-vendor switch for extended thinking. For OpenAI-family reasoning models (`gpt-5.x`, `gpt-6-*`) the gateway maps it to `reasoning_effort`: `{"type": "disabled"}` becomes `reasoning_effort: "none"`, `{"type": "enabled"}` leaves the model's default effort, and an explicit `reasoning_effort` always wins. **response\_format** `object` An object specifying the format that the model must output. **format types** **text** **type** `string` *required* Value: `text`. **json\_object** **type** `string` *required* Value: `json_object`. **json\_schema** **type** `string` *required* Value: `json_schema`. **json\_schema** `object` *required* JSON Schema configuration. **properties** **name** `string` *required* Schema name (a-z, A-Z, 0-9, underscores, dashes, max 64 chars). **description** `string` Schema description for the model. **schema** `object` JSON Schema object. **strict** `boolean | null` Enable strict schema adherence. **grammar** **type** `string` *required* Value: `grammar`. **grammar** `string` *required* Custom grammar for text generation. **seed** `integer | null` If specified, the system will make a best effort to sample deterministically, such that repeated requests with the same seed and parameters should return the same result. **tools** `object[]` A list of tools the model may call. **properties** **type** `string` *required* The type of the tool. Value: `function`. **function** `object` *required* The function definition. **properties** **name** `string` *required* Function name (a-z, A-Z, 0-9, underscores, dashes, max 64 chars). **description** `string` A description of what the function does. **parameters** `object` The parameters the function accepts, described as a JSON Schema object. **strict** `boolean | null` Whether to enable strict schema adherence. **cache\_control** `object` Cache control for this tool definition. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Cache TTL. Supported values: `5m`, `1h`. **tool\_choice** `string | object` Controls which (if any) tool is called by the model. `none` means no tool calls. `auto` means the model decides. `required` means the model must call a tool. Can also specify a particular function. **object variant** **type** `string` *required* Value: `function`. **function** `object` *required* **properties** **name** `string` *required* The name of the function to call. **parallel\_tool\_calls** `boolean | null` (default: true) Whether to enable parallel function calling during tool use. **modalities** `string[]` Output modalities for the response. Supported values: `text`, `image`, `audio`. **metadata** `object` Key-value pairs for additional object information. Maximum 16 pairs, 64 character keys, 512 character values. **cache\_control** `object` Enable automatic prompt caching. When set, the system automatically applies cache breakpoints to the last cacheable block in the request. Currently supported for Anthropic Claude models. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Cache TTL. Supported values: `5m`, `1h`. **user** `string` A unique identifier representing your end-user, which can help monitor and detect abuse. ## Response Successful chat completion response. **id** `string` A unique identifier for the chat completion. Currently the upstream model provider's own id, forwarded as-is — its format varies by provider. This is switching to a Sunra-issued value (breaking change, date to be announced): see [Response identifiers and tracking](/llm/response-tracking#the-id-field-is-switching-to-a-sunra-issued-value). The same id is always available via the [`x-sunra-prediction-id` response header](/llm/response-tracking#the-x-sunra-prediction-id-header), unaffected by that change. **object** `string` The object type. Always `chat.completion`. **created** `number` The Unix timestamp (in seconds) of when the chat completion was created. **model** `string` The model used for the chat completion. **choices** `object[]` A list of chat completion choices. Can be more than one if `n` is greater than 1. **properties** **index** `number` The index of the choice in the list of choices. **finish\_reason** `string` The reason the model stopped generating tokens. Possible values: `stop`, `length`, `tool_calls`, `content_filter`, `error`. **message** `object` A chat completion message generated by the model. **properties** **role** `string` Always `assistant`. **content** `string | null` The text contents of the message. Null when tool\_calls are present. **tool\_calls** `object[]` The tool calls generated by the model. **properties** **id** `string` The ID of the tool call. **type** `string` Value: `function`. **function** `object` The function that the model called. **properties** **name** `string` The name of the function to call. **arguments** `string` The arguments in JSON string format. **refusal** `string | null` Refusal message if the content was refused. **reasoning** `string | null` Reasoning output text. **reasoning\_details** `object[]` Detailed reasoning information for extended thinking models. **reasoning detail types** **summary** **type** `string` Value: `reasoning.summary`. **summary** `string` The reasoning summary text. **id** `string | null` Detail ID. **format** `string | null` Format identifier. **encrypted** **type** `string` Value: `reasoning.encrypted`. **data** `string` Encrypted reasoning data. **id** `string | null` Detail ID. **format** `string | null` Format identifier. **text** **type** `string` Value: `reasoning.text`. **text** `string | null` The reasoning text. **signature** `string | null` Verification signature. **id** `string | null` Detail ID. **format** `string | null` Format identifier. **images** `object[]` Generated images. Each item contains `image_url.url`. **audio** `object` Audio output data. **properties** **id** `string` Audio output identifier. **data** `string` Base64 encoded audio data. **transcript** `string` Audio transcript. **expires\_at** `number` Audio expiration timestamp. **logprobs** `object | null` Log probability information for the choice. **properties** **content** `object[] | null` Log probabilities for content tokens. Each item contains `token`, `logprob`, `bytes`, and `top_logprobs`. **refusal** `object[] | null` Log probabilities for refusal tokens. **usage** `object` Usage statistics for the completion request. **properties** **prompt\_tokens** `number` Number of tokens in the prompt. **completion\_tokens** `number` Number of tokens in the generated completion. **total\_tokens** `number` Total number of tokens used in the request (prompt + completion). **prompt\_tokens\_details** `object | null` Detailed prompt token usage. **properties** **cached\_tokens** `number` Number of cached prompt tokens. **cache\_write\_tokens** `number` Tokens written to cache. Only returned for models with explicit caching. **audio\_tokens** `number` Audio input tokens. **video\_tokens** `number` Video input tokens. **completion\_tokens\_details** `object | null` Detailed completion token usage. **properties** **reasoning\_tokens** `number | null` Tokens used for reasoning. **audio\_tokens** `number | null` Tokens used for audio output. **accepted\_prediction\_tokens** `number | null` Accepted prediction tokens. **rejected\_prediction\_tokens** `number | null` Rejected prediction tokens. **system\_fingerprint** `string | null` This fingerprint represents the backend configuration that the model runs with. Can be used with the `seed` parameter to understand when backend changes have been made. ```bash cURL theme={null} curl -X POST https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "messages": [ { "role": "system", "content": "You are a helpful assistant." }, { "role": "user", "content": "What is the capital of France?" } ] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api-llm.sunra.ai/v1/chat/completions", headers={ "Authorization": "Bearer ", "Content-Type": "application/json" }, json={ "model": "google/gemini-2.5-flash", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is the capital of France?"} ] } ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api-llm.sunra.ai/v1/chat/completions", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json" }, body: JSON.stringify({ model: "google/gemini-2.5-flash", messages: [ { role: "system", content: "You are a helpful assistant." }, { role: "user", content: "What is the capital of France?" } ] }) }); const data = await response.json(); console.log(data); ``` ```json 200 theme={null} { "id": "chatcmpl-abc123", "object": "chat.completion", "created": 1677652288, "model": "google/gemini-2.5-flash", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "The capital of France is Paris.", "refusal": null }, "finish_reason": "stop", "logprobs": null } ], "system_fingerprint": "fp_44709d6fcb", "usage": { "prompt_tokens": 25, "completion_tokens": 8, "total_tokens": 33, "prompt_tokens_details": null, "completion_tokens_details": null } } ``` # Create embeddings Source: https://docs.sunra.ai/llm/embeddings POST https://api-llm.sunra.ai/v1/embeddings Creates embedding vectors from text or media inputs. Text-only requests return one vector per input, useful for retrieval, semantic search, clustering, classification, and RAG. Requests containing media (images, audio, or video as base64 data URIs) fuse **all** inputs into a **single** cross-modal vector — embed each asset in its own request when you need one vector per asset. Compatible with the OpenAI Embeddings API format. Streaming is not supported on this endpoint. ## Authentication **Authorization** `string` *required* Bearer token. Use your API key as the bearer token in the Authorization header. Format: `Bearer ` ## Request This endpoint expects an object. **model** `string` *required* ID of the embedding model to use, e.g. `google/gemini-embedding-2`. See the [models page](https://sunra.ai/models) for available embedding models. **provider** `object` Optional provider routing preferences. Omit for automatic routing. See [Provider routing](/llm/provider-routing) for supported fields and provider discovery. **input** `string | string[]` *required* Inputs to embed, as a single string or an array of strings. Text-only inputs are embedded independently, returning one vector per input in input order. Media inputs are passed as base64 data URIs (`data:image/png;base64,…`, `data:audio/mpeg;base64,…`, `data:video/mp4;base64,…`) and may be mixed with text — but any request containing media returns a single fused vector for all inputs combined. Limits (upstream): at most 6 images per request, video up to 120 seconds, and a combined budget of 8,192 tokens across all modalities (text tokens; images count 258 tokens each, audio 25 tokens/second, video 66 tokens/second). **dimensions** `integer` The number of dimensions for the output embeddings. Only supported by models with flexible output dimensions (Matryoshka Representation Learning). For `google/gemini-embedding-2` the default is `3072`, and values from `128` to `3072` are supported. Smaller values truncate the vector while preserving most semantic quality. **encoding\_format** `string` The format of the returned embeddings. Either `float` (default) or `base64`. ## Response > [!NOTE] > > Unlike Chat Completions, Messages, and Responses, the Embeddings response has no `id` field — the protocol has never had one. To track or look up a specific embeddings request, use the [`x-sunra-prediction-id` response header](/llm/response-tracking#the-x-sunra-prediction-id-header). **object** `string` Always `list`. **data** `object[]` A list of embedding objects, one per input, in input order. **embedding object** **object** `string` Always `embedding`. **index** `integer` The index of the corresponding input. **embedding** `number[]` The embedding vector. **model** `string` The model used to create the embeddings. **usage** `object` Token usage for the request. Embeddings are billed on input tokens only, with modality-specific rates (see the model page for pricing). Media requests include per-modality token counts. **usage object** **prompt\_tokens** `integer` Number of text input tokens. **input\_image\_tokens** `integer` Image input tokens (258 per image). Present only when the request contains images. **input\_audio\_tokens** `integer` Audio input tokens (25 per second). Present only when the request contains audio. **input\_video\_tokens** `integer` Video input tokens (66 per second). Present only when the request contains video. **total\_tokens** `integer` Total input tokens across all modalities (embeddings produce no output tokens). ```bash cURL theme={null} curl -X POST https://api-llm.sunra.ai/v1/embeddings \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-embedding-2", "input": [ "The quick brown fox jumps over the lazy dog.", "Sunra is a platform for AI models." ], "dimensions": 768 }' ``` ```python Python theme={null} import requests response = requests.post( "https://api-llm.sunra.ai/v1/embeddings", headers={ "Authorization": "Bearer ", "Content-Type": "application/json" }, json={ "model": "google/gemini-embedding-2", "input": [ "The quick brown fox jumps over the lazy dog.", "Sunra is a platform for AI models." ], "dimensions": 768 } ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api-llm.sunra.ai/v1/embeddings", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json" }, body: JSON.stringify({ model: "google/gemini-embedding-2", input: [ "The quick brown fox jumps over the lazy dog.", "Sunra is a platform for AI models." ], dimensions: 768 }) }); const data = await response.json(); console.log(data); ``` ```json 200 theme={null} { "object": "list", "data": [ { "object": "embedding", "index": 0, "embedding": [0.011253, -0.020551, 0.049236, "..."] }, { "object": "embedding", "index": 1, "embedding": [0.031744, 0.008122, -0.014307, "..."] } ], "model": "google/gemini-embedding-2", "usage": { "prompt_tokens": 18, "total_tokens": 18 } } ``` # Output limits and stream lifetime Source: https://docs.sunra.ai/llm/limits Four things can end an LLM response before the model decides it is finished: the output ceiling you asked for, the ceiling the model itself enforces, the edge timeout that governs every non-streaming request, and — on a streamed request — the gateway's stream lifetime limits. This page documents all four, and how a request that ends early is billed. It also covers what a streamed request looks like [before the provider has responded at all](#before-the-provider-responds), which is where a long-thinking reasoning model spends most of its time. ## Requesting an output ceiling | Endpoint | Field | | - | - | | `/v1/chat/completions` | `max_completion_tokens` (preferred), `max_tokens` (deprecated) | | `/v1/messages` | `max_tokens` | | `/v1/responses` | `max_output_tokens` | Sunra forwards the ceiling you send to the upstream provider. When a response stops because it hit that ceiling, it reports `finish_reason: "length"` (Chat Completions and Responses) or `stop_reason: "max_tokens"` (Messages) — the same signal you would get calling the provider directly. > [!NOTE] > > On Chat Completions, some providers accept only `max_tokens` and ignore `max_completion_tokens` — an unbounded completion when you believed you had set a ceiling. For the `deepseek/` models the gateway translates `max_completion_tokens` into `max_tokens` on your behalf. If you send both, the smaller of the two wins, since either one is an upper bound. > > Providers that honor `max_completion_tokens` natively are left alone, so a ceiling you set is enforced exactly once, wherever it is enforced. ## Model output ceilings Each model has its own maximum output length, independent of what you request. Asking for more than a model allows is **rejected, not silently reduced** — the upstream provider returns `400` with the valid range in the message. For example, `deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro` accept up to 393,216 output tokens and reject anything above it: ```json theme={null} { "error": { "message": "Invalid max_tokens value, the valid range of max_tokens is [1, 393216]", "type": "invalid_request_error" } } ``` A `400` is the intended behavior here. A ceiling that is quietly lowered would be indistinguishable from a completion that legitimately ran to its limit: both report `finish_reason: "length"`, and nothing in `usage` would tell you which happened. Output ceilings are per model and change as providers ship new versions. Treat the `400` as the authoritative answer rather than hard-coding a limit in your client. ## Non-streaming requests: the edge timeout `api-llm.sunra.ai` is served through a CDN edge that gives up on a response which takes too long to arrive. On a **non-streaming** request, a generation still running when that timer expires is terminated **at the edge**, before the gateway can say anything about it. Depending on timing and connection reuse, the cut shows up as one of: * HTTP `524` with no body * the connection closed without a response (`RemoteDisconnected` / `ECONNRESET`) * a TLS-level EOF with no `close_notify` (`SSL: UNEXPECTED_EOF_WHILE_READING`) None of these carries an error payload — the response never started, so there was nothing to attach one to. When this signature appears only on your long-running requests while short ones sail through, it is this timeout, not a network fault. Do not treat the timer as a precise budget. The CDN's documented default is 125 seconds, but measured cuts on this endpoint have landed anywhere from about 112 seconds upward, and the value is the CDN's to change. A request finishing in 83 seconds is comfortably safe; one aiming at 120 is not. The gateway behind the edge allows a non-streaming request 6 minutes, but that ceiling is unreachable from the public endpoint: the edge gives up first, and it is the edge that closes the connection. **If a request may generate for more than \~90 seconds, send it with `stream: true`.** On a streamed request the gateway responds within about 60 seconds even if the provider has not — see [Before the provider responds](#before-the-provider-responds) — and then keeps bytes flowing, so the edge timer never fires; long generations are instead governed by the far more generous [stream lifetime](#stream-lifetime) limits below. As a sizing aid: models typically emit 30–80 tokens per second, so an output ceiling in the several thousands of tokens — or a reasoning model that thinks at length before its first output token — will not fit inside the window. If your application wants the whole response as a single object, stream anyway and assemble the deltas client-side; the stream's final events carry the same `finish_reason` you would have received non-streaming. ## Before the provider responds A reasoning model often sends **nothing at all** until it has finished thinking — not a first token, not even HTTP response headers. Measured first-header times on this gateway range from a few seconds to well past six minutes on the heaviest reasoning requests. On a streamed request the gateway no longer waits in silence for that. If the provider has not responded within about **60 seconds**, the gateway commits the response on its own: ``` HTTP/1.1 200 OK content-type: text/event-stream; charset=utf-8 cache-control: no-cache x-sunra-prediction-id: chatcmpl_… ``` and then sends [`: keepalive`](#keepalive-comments) comments until the provider starts. Your client sees a normal SSE stream that is briefly quiet; the provider's own frames follow when they arrive. This has one consequence worth designing for. Once that `200` is on the wire, an error can no longer be an HTTP status code — so **a provider error that arrives after the commit is delivered inside the stream**, as an error frame carrying `prediction_id` and the provider's original status in `upstream_status`: ``` data: {"error":{"message":"Rate limit reached for requests","type":"rate_limit_error","code":"rate_limit_exceeded","upstream_status":429,"prediction_id":"chatcmpl_…"}} data: [DONE] ``` On `/v1/messages` the same content arrives in the Anthropic dialect: ``` event: error data: {"type":"error","error":{"type":"rate_limit_error","message":"Rate limit reached for requests","code":"rate_limit_exceeded","upstream_status":429,"prediction_id":"msg_…"}} ``` The official OpenAI and Anthropic SDKs raise on both shapes, so SDK users see an exception either way — only the exception's class changes (an API-status error becomes a stream error). If you parse the stream by hand, treat any frame carrying an `error` object as terminal. **Errors that arrive within the first 60 seconds are unchanged**: they are still native HTTP statuses with the provider's body, exactly as before. In practice that is the overwhelming majority of them — a provider that is going to reject a request rejects it in milliseconds. Non-streaming requests are not affected by any of this; they are still governed by the [edge timeout](#non-streaming-requests-the-edge-timeout). ## Stream lifetime A streamed request (`stream: true`) passes through three phases, each with its own limit: | Phase | Limit | What it catches | | - | - | - | | Waiting for the provider's response headers | 10 minutes | A provider that never starts responding at all | | Between bytes, once the stream has started | 120 seconds with no bytes from the provider | A provider that stopped producing output but has not closed the connection | | Total stream lifetime, measured from those response headers | 30 minutes | A stream that is still producing but will not terminate | The phases are sequential, and the distinction between "response headers" and "first output token" matters: the 120-second idle timeout and the 30-minute ceiling both start when the provider's **response headers** arrive, not when the request was sent. A provider that thinks for eight minutes before sending headers therefore still gets its full 30 minutes of stream afterwards. The idle timeout only bites a provider that has gone **silent**; a stream delivering tokens is never cut for being slow. The lifetime ceiling is absolute: it applies to an actively delivering stream too, which is the point — it is the backstop against a generation that never terminates. Requests authenticated with a [budget key](/platform/budget-keys) have one more bound: a separate 29-minute gateway deadline, started just before the key admits the call, covers the whole request — admission, the wait for response headers and the stream itself — and ends it in whatever phase it has reached. The deadline is sized one minute under the key's 30-minute hold, as nominal headroom for settlement. On a budget key a stream therefore gets less than 29 minutes minus the time spent on admission and waiting for response headers. Non-streaming requests are subject to none of these; the gateway gives them 6 minutes for the whole request, though the [edge timeout](#non-streaming-requests-the-edge-timeout) above cuts them off well before that. ### Heavy reasoning workloads For `openai/gpt-6-astra`, `reasoning_effort: "xhigh"` on a complex prompt may exceed the roughly 10-minute header budget (630 seconds). Prefer `high` or simplify the task when predictable completion matters. The gateway does not guarantee that every heavy reasoning workload will finish within its limits. Reasoning and visible text share the output-token allowance. Set `max_completion_tokens` (Chat Completions), `max_output_tokens` (Responses), or `max_tokens` (Messages) high enough for both; a small cap can end the response before an answer appears. Reported reasoning usage can still be billable even if no visible answer is returned. On `/v1/responses`, an early `response.created` event does not mean an answer is ready. The separate 30-minute stream lifetime applies from upstream response headers, including time spent reasoning after that event. ### Keepalive comments While a stream is open but the provider is producing nothing, the gateway writes a comment line roughly every 20 seconds so the connection never looks idle to the network between us: ``` : keepalive ``` Per the SSE specification a line beginning with `:` is a comment and must be ignored, and every mainstream client does so — the official OpenAI and Anthropic SDKs discard it before you ever see it. You only need to think about this if you parse the stream by hand: skip any line that starts with `:`, the same way you already skip blank lines. The keepalive carries no meaning. It says nothing about the provider still being alive, and it never resets the idle timeout below. ### The abort frame When either limit fires — or the upstream connection fails mid-stream — the gateway does not drop the connection. It flushes whatever it still holds, sends an error frame in your endpoint's SSE dialect, and closes the stream cleanly. **Everything delivered before the frame is valid output** and can be used. On `/v1/chat/completions` and `/v1/responses`: ``` data: {"error":{"message":"Stream aborted by gateway (stream_lifetime_ceiling); partial output above is complete as delivered","type":"gateway_stream_aborted","code":"stream_lifetime_ceiling"}} data: [DONE] ``` The `[DONE]` sentinel is part of the Chat Completions dialect only; Responses streams end after the error frame without one. On `/v1/messages`, the frame follows the Anthropic dialect — a typed `error` event, and no `[DONE]`: ``` event: error data: {"type":"error","error":{"type":"gateway_stream_aborted","message":"Stream aborted by gateway (upstream_idle_timeout); partial output above is complete as delivered","code":"upstream_idle_timeout"}} ``` For a **gateway**-authored abort, `type` is always `gateway_stream_aborted`, and `code` — present in every dialect — names what happened: | Code | Retry? | Meaning | | - | - | - | | `upstream_idle_timeout` | Yes — may be transient | The provider sent nothing for 120 seconds. | | `stream_lifetime_ceiling` | Not as-is | The stream reached 30 minutes (or, on a budget key, its 29-minute admission deadline). The same request will likely reach it again — lower your output ceiling, or split the work into several requests. | | `upstream_stream_error` | Yes — may be transient | The connection to the provider failed mid-stream. | | `upstream_empty_stream` | Yes — may be transient | The provider ended an apparently successful stream without ever producing output or usage. Nothing was delivered, and the request is not billed. | | `server_shutdown` | Yes — retry | The gateway instance is shutting down and the request exceeded its five-minute drain window. This is a deployment interruption. | | `pre_stream_timeout` | Not as-is | The provider never sent response headers within 10 minutes. Nothing was delivered, and the request is not billed. | | `upstream_fetch_failed` | Yes — may be transient | The connection to the provider failed before the stream started. Nothing was delivered, and the request is not billed. | | `upstream_not_streaming` | Yes — may be transient | The provider answered with something that is not an SSE stream after the gateway had already committed one. Nothing was delivered, and the request is not billed. | `server_shutdown` can arrive before any output or during generation. Keep any partial output already received; retrying starts a new request and does not resume that output. Billing follows the aborted-stream rules below. If the gateway has not committed response headers, including for a non-streaming request, shutdown returns HTTP `503` with `error.type: "gateway_error"` and `error.code: "server_shutdown"`. A request refused after shutdown starts receives the same status and code. `upstream_empty_stream` is the one code that does not come from a limit or a broken connection: the stream ended cleanly, just empty. Because there is no partial output to keep, its frame carries a different message — `Upstream returned an empty stream: the response ended before any billable output or terminal usage was received` — and is the entire visible result of the request. The last three codes can only appear on a request that had already been [committed before the provider responded](#before-the-provider-responds), so by construction there is no partial output above them; their message says so — `Upstream failed before the stream started (); no output was produced`. A **provider** error delivered after that commit is a different shape again: it keeps the provider's own `type` and `code` rather than `gateway_stream_aborted`, and adds `upstream_status`. Use the presence of `upstream_status` to tell "the provider rejected this" from "the gateway ended the stream". A stream that ends with neither an abort frame nor its normal terminal events was cut at the transport level. The gateway writes the abort frame whenever the connection to you is still writable, but it cannot promise one in every case: if the connection is already gone — a socket error, or an intermediary that gave up on an idle connection — there is nothing left to write it to. So treat a **truncated stream with no abort frame** as an interrupted connection, not as a completed response, and not as proof that the fault is on your side. Everything delivered before the cut is still valid output. ### Billing for an aborted stream An aborted stream is billed for what was delivered to you, never for the full generation the provider may have continued internally. * If the provider had already reported final usage before the abort, that report is billed. * Otherwise the delivered output is settled on a **conservative estimate** — roughly four characters per token, capped at the output ceiling you requested. The completion records `usage_source: "conservative_estimate"` for the record. * If nothing was delivered, or the provider reported an explicit error, the request is marked failed and the reserved credits are released. Failed requests are not charged. ## Handling this in a client ```python theme={null} for line in response.iter_lines(): if not line.startswith(b"data: "): continue payload = line[6:] if payload == b"[DONE]": break event = json.loads(payload) if error := event.get("error"): if error.get("type") == "gateway_stream_aborted": # Keep what has been collected so far — it is complete as delivered. # error["code"] says whether a retry is worth attempting. handle_partial(collected, reason=error["code"]) break raise UpstreamError(error) collected.append(event["choices"][0]["delta"].get("content", "")) ``` The important part is that the partial output is kept rather than discarded. You are billed for it either way, and for long generations it is usually most of the answer. # LLM Quickstart Source: https://docs.sunra.ai/llm/llm-quickstart Sunra provides four LLM API endpoints for text generation and embeddings. They use the same authentication and base URL (`https://api-llm.sunra.ai`), so you can pick the format that fits your stack. Before diving in, grab an API key from your [dashboard](https://sunra.ai/dashboard/api-tokens). Use canonical model IDs in requests. Sunra selects a provider automatically when `provider` is omitted; see [Provider routing](/llm/provider-routing) when you need to choose or prioritize providers. > [!NOTE] > > Two edge behaviors worth knowing before your first request. Requests carrying certain default HTTP-library `User-Agent` strings — `Python-urllib/3.x` among them — are rejected at the CDN edge with `403` and a plain-text `error code: 1010` body, before authentication ever runs; send a descriptive `User-Agent` naming your application and the request passes normally. And a non-streaming request whose response takes too long to arrive is terminated at the edge, so long generations must use `stream: true` — see [Output limits](/llm/limits#non-streaming-requests-the-edge-timeout). ## Chat Completions — `/v1/chat/completions` The [Chat Completions](/llm/chat) endpoint follows the **OpenAI Chat Completions** format. It accepts a list of messages with roles (`system`, `user`, `assistant`) and returns a completion. Use this endpoint when you want drop-in compatibility with OpenAI SDKs and tooling. **Key features:** streaming, function calling, vision (images, audio, video, files), reasoning, structured outputs (JSON schema / grammar), logprobs. ```bash theme={null} curl -X POST https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "messages": [ { "role": "system", "content": "You are a helpful assistant." }, { "role": "user", "content": "What is the capital of France?" } ] }' ``` ## Anthropic Messages — `/v1/messages` The [Anthropic Messages](/llm/messages) endpoint follows the **Anthropic Messages API** format. It uses `user` / `assistant` message roles with rich content blocks and a separate `system` parameter. Use this endpoint when you want native access to Anthropic Claude models and features like extended thinking, prompt caching, citations, and built-in tools (web search, code execution). **Key features:** streaming, extended thinking, prompt caching, tool use (custom + built-in), PDF/document input, citations, structured outputs. ```bash theme={null} curl -X POST https://api-llm.sunra.ai/v1/messages \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-sonnet-4-6", "max_tokens": 1024, "messages": [ { "role": "user", "content": "Hello, how are you?" } ] }' ``` ## Responses — `/v1/responses` The [Responses](/llm/responses) endpoint follows the **OpenAI Responses API** format. It accepts flexible input items (messages, function calls, reasoning) and returns structured output items. Use this endpoint when you need the latest OpenAI Responses features like built-in web search, file search, code interpreter, computer use, MCP tool integration, or image generation. **Key features:** streaming, function calling, web search, file search, code interpreter, computer use, MCP tools, image generation, reasoning, structured outputs. ```bash theme={null} curl -X POST https://api-llm.sunra.ai/v1/responses \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "input": [ { "type": "message", "role": "user", "content": "Hello, how are you?" } ] }' ``` ## Embeddings — `/v1/embeddings` The [Embeddings](/llm/embeddings) endpoint follows the **OpenAI Embeddings** format. It creates vectors from text and supported media inputs for retrieval, semantic search, clustering, classification, and RAG. ```bash theme={null} curl -X POST https://api-llm.sunra.ai/v1/embeddings \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-embedding-2", "input": "Sunra provides one API for AI models." }' ``` ## Choosing the right endpoint | | Chat Completions | Anthropic Messages | Responses | Embeddings | | - | - | - | - | - | | **Format** | OpenAI Chat | Anthropic Messages | OpenAI Responses | OpenAI Embeddings | | **Best for** | OpenAI SDK compatibility | Claude-native features | Latest OpenAI features | Search and RAG vectors | | **Streaming** | SSE | SSE | SSE | No | | **Function calling** | Yes | Yes (custom + built-in) | Yes | No | | **Reasoning** | Yes | Extended thinking | Yes | No | | **Structured output** | JSON schema, grammar | JSON schema | JSON schema | Vectors | | **Built-in tools** | — | Web search, code execution | Web search, file search, code interpreter, computer use, MCP | — | All four endpoints share the same authentication. Pass your API key as a Bearer token in the `Authorization` header. # Create a message Source: https://docs.sunra.ai/llm/messages POST https://api-llm.sunra.ai/v1/messages Creates a message using the Anthropic Messages API format. Supports text, images, PDFs, tools, and extended thinking. ## Authentication **Authorization** `string` *required* Bearer token. Use your API key as the bearer token in the Authorization header. Format: `Bearer ` ## Request This endpoint expects an object. **model** `string` *required* The model that will complete your prompt. Browse available models at [sunra.ai/models](https://sunra.ai/models). **provider** `object` Optional provider routing preferences. Omit for automatic routing. See [Provider routing](/llm/provider-routing) for supported fields and provider discovery. **messages** `object[] | null` *required* Input messages. Each input message must be an object with a `role` and `content`. You can specify a single `user`-role message, or include multiple `user` and `assistant` messages for multi-turn conversations. **properties** **role** `string` *required* The role of the message author. Supported values: `user`, `assistant`. **content** `string | object[]` *required* The content of the message. Can be a single string or an array of content blocks. **content block types** **TextBlockParam** **type** `string` *required* Value: `text`. **text** `string` *required* The text content. **citations** `object[] | null` Citations for the text block. **cache\_control** `object` Cache control breakpoint at this content block. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Time-to-live. Values: `5m` (default), `1h`. **ImageBlockParam** **type** `string` *required* Value: `image`. **source** `object` *required* Image source. **source variants** **base64** **type** `string` *required* Value: `base64`. **media\_type** `string` *required* Media type: `image/jpeg`, `image/png`, `image/gif`, or `image/webp`. **data** `string` *required* Base64-encoded image data. **url** **type** `string` *required* Value: `url`. **url** `string` *required* Image URL. **cache\_control** `object` Cache control breakpoint at this content block. **DocumentBlockParam** **type** `string` *required* Value: `document`. **source** `object` *required* Document source. **source variants** **base64** **type** `string` *required* Value: `base64`. **media\_type** `string` *required* Value: `application/pdf`. **data** `string` *required* Base64-encoded PDF data. **text** **type** `string` *required* Value: `text`. **media\_type** `string` *required* Value: `text/plain`. **data** `string` *required* Plain text data. **content** **type** `string` *required* Value: `content`. **content** `string | object[]` *required* Content blocks. **url** **type** `string` *required* Value: `url`. **url** `string` *required* PDF URL. **citations** `object` Citations configuration. **properties** **enabled** `boolean` Whether citations are enabled for this document. **context** `string` Additional context for the document. **title** `string` Title of the document. **cache\_control** `object` Cache control breakpoint at this content block. **ToolUseBlockParam** **type** `string` *required* Value: `tool_use`. **id** `string` *required* The ID of the tool use. **name** `string` *required* The name of the tool. **input** `object` *required* The input to the tool. **cache\_control** `object` Cache control breakpoint at this content block. **ToolResultBlockParam** **type** `string` *required* Value: `tool_result`. **tool\_use\_id** `string` *required* The ID of the tool use this result corresponds to. **content** `string | object[]` The result content. Can be a string or an array of content blocks. **is\_error** `boolean` Whether this is an error result. **cache\_control** `object` Cache control breakpoint at this content block. **ThinkingBlockParam** **type** `string` *required* Value: `thinking`. **thinking** `string` *required* The thinking content. **signature** `string` *required* The signature of the thinking block. **RedactedThinkingBlockParam** **type** `string` *required* Value: `redacted_thinking`. **data** `string` *required* The redacted thinking data. **max\_tokens** `number` The maximum number of tokens to generate before stopping. Note that the model may stop before reaching this maximum. Different models have different maximum values for this parameter; a value above a model's own ceiling is rejected with `400` rather than reduced. See [Output limits](/llm/limits). **system** `string | object[]` System prompt. A system prompt is a way of providing context and instructions to the model. Can be a string or an array of `TextBlockParam` objects, each containing `text`, `type` (`"text"`), optional `cache_control`, and optional `citations`. **stream** `boolean` (default: false) Whether to incrementally stream the response using server-sent events (SSE). Streams are subject to an idle timeout and a lifetime ceiling — see [Output limits and stream lifetime](/llm/limits#stream-lifetime). A non-streaming request is terminated at the edge if the response takes too long to arrive, so any request that may generate for more than \~90 seconds must set `stream: true` — see [the edge timeout](/llm/limits#non-streaming-requests-the-edge-timeout). **temperature** `number` (default: 1) Amount of randomness injected into the response. Ranges from 0.0 to 1.0. Use `temperature` closer to 0.0 for analytical/multiple choice tasks, and closer to 1.0 for creative and generative tasks. Note that even with `temperature` of 0.0, the results will not be fully deterministic. For OpenAI reasoning models (`gpt-5.x`, `gpt-6-*`) with reasoning enabled, `temperature` and `top_p` are not supported upstream and are dropped silently by the gateway. **top\_p** `number` Use nucleus sampling. Computes the cumulative distribution over all options for each subsequent token in decreasing probability order and cuts it off once it reaches the probability specified by `top_p`. Recommended for advanced use cases only. You usually only need to use `temperature`. **top\_k** `number` Only sample from the top K options for each subsequent token. Used to remove "long tail" low probability responses. Recommended for advanced use cases only. **stop\_sequences** `string[]` Custom text sequences that will cause the model to stop generating. If the model encounters one of the custom sequences, the response `stop_reason` value will be `"stop_sequence"` and the response `stop_sequence` value will contain the matched stop sequence. **tools** `object[]` Definitions of tools that the model may use. Supports custom tools, Anthropic built-in tools, and server tools. **tool types** **Custom Tool** **name** `string` *required* Name of the tool. This is how the tool will be called by the model. **input\_schema** `object` *required* [JSON schema](https://json-schema.org/draft/2020-12) for the tool's input. This defines the shape of the `input` that your tool accepts and that the model will produce. **description** `string` Description of what this tool does. Tool descriptions should be as detailed as possible. **type** `string` Value: `custom`. **cache\_control** `object` Cache control breakpoint. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Time-to-live. Values: `5m` (default), `1h`. **Bash Tool** **type** `string` *required* Value: `bash_20250124`. **name** `string` *required* Value: `bash`. **cache\_control** `object` Cache control breakpoint. **Text Editor Tool** **type** `string` *required* Value: `text_editor_20250124`. **name** `string` *required* Value: `str_replace_editor`. **cache\_control** `object` Cache control breakpoint. **Web Search Tool** **type** `string` *required* Value: `web_search_20250305`. **name** `string` *required* Value: `web_search`. **allowed\_domains** `string[] | null` If provided, only these domains will be included in results. Cannot be used alongside `blocked_domains`. **blocked\_domains** `string[] | null` If provided, these domains will never appear in results. Cannot be used alongside `allowed_domains`. **max\_uses** `number | null` Maximum number of times the tool can be used in the API request. **user\_location** `object | null` Parameters for the user's location. Used to provide more relevant search results. **properties** **type** `string` *required* Value: `approximate`. **city** `string | null` The city of the user. **country** `string | null` The two letter ISO country code. **region** `string | null` The region of the user. **timezone** `string | null` The IANA timezone of the user. **cache\_control** `object` Cache control breakpoint. **tool\_choice** `object` How the model should use the provided tools. The model can use a specific tool, any available tool, decide by itself, or not use tools at all. **variants** **auto** **type** `string` *required* Value: `auto`. **disable\_parallel\_tool\_use** `boolean` Whether to disable parallel tool use. Defaults to `false`. If `true`, the model will output at most one tool use. **any** **type** `string` *required* Value: `any`. **disable\_parallel\_tool\_use** `boolean` Whether to disable parallel tool use. Defaults to `false`. If `true`, the model will output exactly one tool use. **none** **type** `string` *required* Value: `none`. **tool** **type** `string` *required* Value: `tool`. **name** `string` *required* The name of the tool to use. **disable\_parallel\_tool\_use** `boolean` Whether to disable parallel tool use. Defaults to `false`. If `true`, the model will output exactly one tool use. **thinking** `object` Configuration for enabling Claude's extended thinking. When enabled, responses include `thinking` content blocks showing Claude's thinking process before the final answer. Requires a minimum budget of 1,024 tokens. For OpenAI-family reasoning models (`gpt-5.x`, `gpt-6-*`) the gateway maps this to `reasoning_effort`: `{"type": "disabled"}` becomes `reasoning_effort: "none"`, `budget_tokens` still selects the effort level, and an explicit `reasoning_effort` always wins. **variants** **enabled** **type** `string` *required* Value: `enabled`. **budget\_tokens** `integer` *required* Determines how many tokens Claude can use for its internal reasoning process. Must be ≥1024 and less than `max_tokens`. **disabled** **type** `string` *required* Value: `disabled`. **adaptive** **type** `string` *required* Value: `adaptive`. **output\_config** `object` Configuration for controlling output behavior. Supports the effort parameter and structured output format. **properties** **effort** `string | null` How much effort the model should put into its response. Higher effort levels may result in more thorough analysis but take longer. Valid values: `low`, `medium`, `high`, `max`. **format** `object | null` A schema to specify Claude's output format in responses (structured outputs). **properties** **type** `string` *required* Value: `json_schema`. **schema** `object` *required* The JSON schema of the format. **cache\_control** `object` Top-level cache control. Automatically applies a cache\_control marker to the last cacheable block in the request. **properties** **type** `string` *required* Value: `ephemeral`. **ttl** `string` Time-to-live for the cache control breakpoint. Values: `5m` (5 minutes, default), `1h` (1 hour). **service\_tier** `string` Determines whether to use priority capacity or standard capacity for this request. Supported values: `auto`, `standard_only`. **metadata** `object` An object describing metadata about the request. **properties** **user\_id** `string` An external identifier for the user who is associated with the request. This should be a uuid, hash value, or other opaque identifier. ## Response Successful message response. **id** `string` Unique message identifier, e.g. `msg_01XFDUDYJgAACzvnptvVoYEL`. Currently the upstream model provider's own id, forwarded as-is. This is switching to a Sunra-issued value (breaking change, date to be announced): see [Response identifiers and tracking](/llm/response-tracking#the-id-field-is-switching-to-a-sunra-issued-value). The same id is always available via the [`x-sunra-prediction-id` response header](/llm/response-tracking#the-x-sunra-prediction-id-header), unaffected by that change. On streaming responses, the equivalent value is `message_start.message.id`. **type** `string` Object type. Always `message`. **role** `string` Conversational role of the generated message. Always `assistant`. **container** `object | null` Information about the container used in the request (for the code execution tool). **properties** **id** `string` Identifier for the container used in this request. **expires\_at** `string` The time at which the container will expire. **content** `object[]` Content generated by the model. This is an array of content blocks, each of which has a `type` that determines its shape. **content block types** **TextBlock** **type** `string` Value: `text`. **text** `string` The generated text. **citations** `object[] | null` Citations supporting the text block. Can be `char_location`, `page_location`, `content_block_location`, `web_search_result_location`, or `search_result_location`. **ToolUseBlock** **type** `string` Value: `tool_use`. **id** `string` The ID of the tool use block. **name** `string` The name of the tool. **input** `object` The input to the tool as generated by the model. **ThinkingBlock** **type** `string` Value: `thinking`. **thinking** `string` The thinking content. **signature** `string` The signature of the thinking block. **RedactedThinkingBlock** **type** `string` Value: `redacted_thinking`. **data** `string` The redacted data. **ServerToolUseBlock** **type** `string` Value: `server_tool_use`. **id** `string` The ID of the tool use block. **name** `string` The server tool name. e.g., `web_search`, `web_fetch`, `code_execution`. **input** `object` The input to the server tool. **WebSearchToolResultBlock** **type** `string` Value: `web_search_tool_result`. **tool\_use\_id** `string` The ID of the tool use this result corresponds to. **content** `object | object[]` Search results or error object. **WebFetchToolResultBlock** **type** `string` Value: `web_fetch_tool_result`. **tool\_use\_id** `string` The ID of the tool use this result corresponds to. **content** `object` Fetched content or error object. **CodeExecutionToolResultBlock** **type** `string` Value: `code_execution_tool_result`. **tool\_use\_id** `string` The ID of the tool use this result corresponds to. **content** `object` Execution result or error object. **model** `string` The model that handled the request. **stop\_reason** `string | null` The reason that the model stopped generating. Possible values: * `end_turn` — the model reached a natural stopping point * `max_tokens` — exceeded `max_tokens` or the model's maximum * `stop_sequence` — one of your custom stop sequences was generated * `tool_use` — the model invoked one or more tools * `pause_turn` — a long-running turn was paused * `refusal` — streaming classifiers intervened for potential policy violations **stop\_sequence** `string | null` Which custom stop sequence was generated, if any. **usage** `object` Billing and rate-limit usage. The three input buckets are mutually exclusive — see [Token usage](/llm/token-usage). **properties** **input\_tokens** `integer` The number of fresh input tokens which were used. Excludes both cache buckets. **output\_tokens** `integer` The number of output tokens which were used. **total\_tokens** `integer` All input buckets plus `output_tokens`. Omitted on streamed responses. **cache\_creation\_input\_tokens** `integer | null` The number of input tokens used to create the cache entry. **cache\_read\_input\_tokens** `integer | null` The number of input tokens read from the cache. **sunra\_usage\_semantics** `string | null` Present as `anthropic.exclusive.v1` when Sunra normalized this response. Assert on this value rather than inferring the convention from the numbers. See [Token usage](/llm/token-usage). **cache\_creation** `object | null` Breakdown of cached tokens by TTL. **properties** **ephemeral\_5m\_input\_tokens** `integer` The number of input tokens used to create the 5 minute cache entry. **ephemeral\_1h\_input\_tokens** `integer` The number of input tokens used to create the 1 hour cache entry. **inference\_geo** `string | null` The geographic region where inference was performed for this request. **server\_tool\_use** `object | null` The number of server tool requests. **properties** **web\_search\_requests** `integer` The number of web search tool requests. **web\_fetch\_requests** `integer` The number of web fetch tool requests. **service\_tier** `string | null` If the request used the priority, standard, or batch tier. Values: `standard`, `priority`, `batch`. ```bash cURL theme={null} curl -X POST https://api-llm.sunra.ai/v1/messages \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-sonnet-4-6", "max_tokens": 1024, "messages": [ { "role": "user", "content": "Hello, how are you?" } ] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api-llm.sunra.ai/v1/messages", headers={ "Authorization": "Bearer ", "Content-Type": "application/json" }, json={ "model": "anthropic/claude-sonnet-4-6", "max_tokens": 1024, "messages": [ {"role": "user", "content": "Hello, how are you?"} ] } ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api-llm.sunra.ai/v1/messages", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json" }, body: JSON.stringify({ model: "anthropic/claude-sonnet-4-6", max_tokens: 1024, messages: [ { role: "user", content: "Hello, how are you?" } ] }) }); const data = await response.json(); console.log(data); ``` ```json 200 theme={null} { "id": "msg_01XFDUDYJgAACzvnptvVoYEL", "type": "message", "role": "assistant", "container": null, "content": [ { "type": "text", "text": "Hello! I'm doing well, thank you for asking. How can I help you today?", "citations": null } ], "model": "anthropic/claude-sonnet-4-6", "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 12, "output_tokens": 19, "total_tokens": 31, "cache_creation_input_tokens": null, "cache_read_input_tokens": null, "cache_creation": null, "inference_geo": null, "server_tool_use": null, "service_tier": null, "sunra_usage_semantics": "anthropic.exclusive.v1" } } ``` # Provider routing Source: https://docs.sunra.ai/llm/provider-routing Sunra routes a canonical model ID to an available provider automatically. In most requests, omit `provider` and let Sunra select the route. ```json theme={null} { "model": "google/gemini-2.5-flash", "messages": [ { "role": "user", "content": "Hello" } ] } ``` Use the optional `provider` object when you need a specific provider or want to control the eligible provider set. Provider routing is supported by Chat Completions, Messages, Responses, and Embeddings. ## Discover available providers Provider availability varies by model and API format. Query the discovery endpoint before presenting provider choices in an application: ```bash theme={null} curl "https://api-llm.sunra.ai/v1/models/google/gemini-2.5-flash/providers?format=chat%2Fcompletions" \ -H "Authorization: Bearer " ``` ```json theme={null} { "object": "list", "data": [ { "id": "google-vertexai", "name": "Google Vertex AI", "supported_formats": ["chat/completions", "messages", "responses"] }, { "id": "openrouter", "name": "OpenRouter", "supported_formats": ["chat/completions", "messages", "responses"] } ] } ``` The discovery endpoint uses the same API key authentication as the LLM inference endpoints. Supported `format` values are `chat/completions`, `messages`, `responses`, and `embeddings`. ## Select one provider Set `provider.only` to restrict the request to one provider: ```bash theme={null} curl -X POST https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "provider": { "only": ["google-vertexai"] }, "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' ``` The request returns an error when none of the requested providers can serve the model and API format. ## Set a preference order Use `order` to prefer providers in sequence. Providers not listed in `order` remain eligible unless `allow_fallbacks` is `false`. ```json theme={null} { "provider": { "order": ["google-vertexai", "openrouter"], "allow_fallbacks": false } } ``` `allow_fallbacks` controls the initial eligible provider set. It does not retry a request through another provider after an upstream response or failure has started. ## Exclude providers Use `ignore` to remove providers from automatic routing: ```json theme={null} { "provider": { "ignore": ["openrouter"] } } ``` ## Prefer the lowest price Use `sort: "price"` to prefer the lowest-priced eligible route. Price sorting applies when `order` is omitted. ```json theme={null} { "provider": { "sort": "price" } } ``` `latency` and `throughput` sorting are not currently supported. ## Provider fields **provider** `object` Optional routing preferences. Omit this object for automatic routing. **properties** **only** `string[]` Restrict routing to these provider IDs. **order** `string[]` Prefer providers in this order. **ignore** `string[]` Exclude these providers from routing. **allow\_fallbacks** `boolean` When `order` is present, allow providers not listed in `order` to remain eligible. Defaults to `true`. **sort** `string` Sort eligible providers. Currently supported value: `price`. # Response identifiers and tracking Source: https://docs.sunra.ai/llm/response-tracking Every LLM request Sunra accepts gets a Sunra-issued id — the same one whether you called Chat Completions, Messages, Responses, or Embeddings. This page documents where that id shows up, how to use it to reconcile a request after the fact, and a breaking change to where the `id` field in the response body points. ## The `x-sunra-prediction-id` header Every response from `api-llm.sunra.ai` — success, failure, streaming or not — carries an `x-sunra-prediction-id` header once the request has cleared authentication, been routed to a model, and had its record durably persisted: ```bash cURL theme={null} curl -sD - https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "messages": [{ "role": "user", "content": "Hello" }] }' -o /dev/null ``` ``` HTTP/2 200 x-sunra-prediction-id: chatcmpl_hGRKYZJjNDvu474VJSZGdQyX content-type: application/json ... ``` > [!NOTE] > > The header name says `prediction` rather than `completion` — it reuses the same header Sunra's multimodal API sets on predictions. Both APIs identify a unit of work the same way; the name is not specific to images or video. The header is exposed for browser callers too (`Access-Control-Expose-Headers` includes it), so `fetch` in a browser can read it without a server-side proxy. **When it's absent.** Persisting the completion record is the one thing that has to happen before an id exists to report. If that persistence step itself fails — a rare internal error, distinct from the request or the model failing — the response is a `5xx` with no `x-sunra-prediction-id` header, because there is nothing to report yet. Every other failure (rejected input, an upstream provider error, insufficient credit, a stream that gets aborted) happens after the record exists, so the header is present on those. ## Errors carry the id too Once the completion record exists, every error response also carries the id in the body — not just the header. This covers 4xx/5xx responses, a non-2xx JSON body from the upstream provider, an in-stream error event on an otherwise-`200` SSE response, and the [stream abort frame](/llm/limits#the-abort-frame). Where the id lands depends on the shape of the error body, because Sunra never restructures an error shape to fit a new field — it slots the id into whichever shape is already there: | Error body shape | Where the id appears | | - | - | | An object-shaped `error` — `{"error": {"message": ..., "type": ...}}` and its variants across Chat Completions, Messages, Responses, and their streaming error events | `error.prediction_id`, alongside `error.message` / `error.type` | | Any other recognized error shape — `error` as a plain string, a body with only `detail`, a Responses-style `{"status": "failed", "error": {...}}` payload, and so on | top-level `prediction_id`, alongside the existing fields | ```json Object-shaped error theme={null} { "error": { "message": "Insufficient credit to reserve for this request.", "type": "insufficient_credit", "prediction_id": "chatcmpl_hGRKYZJjNDvu474VJSZGdQyX" } } ``` ```json Non-object error shape theme={null} { "detail": "The requested model does not support this input.", "prediction_id": "chatcmpl_hGRKYZJjNDvu474VJSZGdQyX" } ``` The [stream abort frame](/llm/limits#the-abort-frame) gets the same treatment — the id sits inside the frame's own `error` object: ``` data: {"error":{"message":"Stream aborted by gateway (stream_lifetime_ceiling); partial output above is complete as delivered","type":"gateway_stream_aborted","code":"stream_lifetime_ceiling","prediction_id":"chatcmpl_hGRKYZJjNDvu474VJSZGdQyX"}} ``` As with the header, a pre-persistence failure carries no `prediction_id` anywhere in the body — there was never an id minted for that request. ## The `id` field is switching to a Sunra-issued value > [!WARNING] > > **Breaking change — live since 2026-09-02 \~02:35 UTC.** All LLM inference responses now return the Sunra-issued `chatcmpl_` id; upstream provider ids no longer appear in response bodies. See [Migrating existing integrations](#migrating-existing-integrations) below. Today, the `id` in a Chat Completions, Messages, or Responses body is the upstream model provider's own identifier, forwarded as-is. Its format varies by provider and by protocol — `chatcmpl-` on some Chat Completions providers, `msg_` on Messages, `resp_` on Responses, or something else entirely depending on who served the request. After this change, `id` becomes the **same Sunra-issued value as the `x-sunra-prediction-id` header** — `chatcmpl_` followed by a 24-character identifier — on every protocol, including `/v1/responses` (which will no longer return a `resp_`-prefixed id): | Endpoint | Where `id` appears | | - | - | | `/v1/chat/completions` | top-level `id` — non-streaming, and on every streamed chunk | | `/v1/messages` | top-level `id` — non-streaming; `message_start.message.id` — streaming | | `/v1/responses` | top-level `id` — non-streaming; `response.id` on `response.created`, `response.completed`, and every other response-carrying stream event | **Only the envelope id changes.** Any `id` nested inside the generated content itself — a tool call id, a reasoning item id, a message item id inside `output` — is untouched. SSE's own protocol-level `id:` field (used for reconnection, distinct from anything inside the JSON payload) is also untouched. The upstream provider's id is not discarded — it moves to `provider_response_id`, retrievable through [`GET /completions?completion_id=`](#looking-up-a-completion-later) below, where it always has been for reconciliation purposes. ## Embeddings: no `id` field The Embeddings protocol has never had an `id` field, on Sunra or on the API it mirrors, and this change does not add one — introducing a field outside the documented response shape would itself be a breaking change for a strict response parser. For an embeddings request, the [`x-sunra-prediction-id` header](#the-x-sunra-prediction-id-header) is the only place to find the tracking id. ## Looking up a completion later Use an id — from the header, or from the body `id` once the [switch](#the-id-field-is-switching-to-a-sunra-issued-value) above ships — to fetch the completion's full record: ```bash cURL theme={null} curl "https://api.sunra.ai/v1/completions?completion_id=chatcmpl_hGRKYZJjNDvu474VJSZGdQyX" \ -H "Authorization: Key " ``` The fields most relevant to reconciliation: | Field | Description | | - | - | | `id` | The same Sunra-issued id, echoed back. | | `provider_response_id` | The upstream model provider's own id for this completion — the value that used to be the body `id`, and remains available here regardless of when the request happened. | | `status`, `model`, `usage`, `invoice` | The completion's status, model, token usage, and billing record. | This is the same endpoint whether the completion predates or postdates the `id` cutover — pass whichever id you have (a Sunra `chatcmpl_` id, or a provider-native id captured from a response before the switch) and it resolves the same record. ## Continuing a conversation: `previous_response_id` On `/v1/responses`, pass the `id` you got back from a prior response as `previous_response_id` to continue that conversation: ```json theme={null} { "model": "openai/gpt-5.1", "previous_response_id": "chatcmpl_hGRKYZJjNDvu474VJSZGdQyX", "input": "And in French?" } ``` Send exactly the id Sunra gave you. The gateway resolves it to whatever the upstream provider needs internally, so continuation keeps working across the `id` field's [switch to a Sunra-issued value](#the-id-field-is-switching-to-a-sunra-issued-value) — you never need to know which upstream shape is behind a given response. An id Sunra doesn't recognize — mistyped, belonging to a different organization, or one it never linked a continuation for — is rejected with `400 invalid_request_error` rather than silently starting a fresh conversation. > [!NOTE] > > **Transition period.** Sunra also still accepts the upstream provider's own native response id (a pre-cutover `id` value, e.g. `resp_...`) in `previous_response_id`, so continuations started before the switch keep working. This fallback is temporary and will be removed once the migration window closes — send the id Sunra returns rather than relying on it. **If linking fails.** In rare cases the gateway cannot record the mapping needed to translate `previous_response_id` for one specific response (an internal write failure). That response still delivers normally and is billed normally — nothing about the conversation you already had is affected. The only consequence is narrow: a *follow-up* request that names that one response's id as `previous_response_id` gets `400 invalid_request_error` instead of continuing. Start a new conversation (omit `previous_response_id`) and carry on from there. ## Migrating existing integrations This section is for callers who already parse or store the `id` from a Chat Completions, Messages, or Responses response, ahead of the [breaking `id` change](#the-id-field-is-switching-to-a-sunra-issued-value) above. **Who is affected:** * Anything that parses `id` to infer the upstream provider or model family — its format is provider-specific today; after the change every provider returns the same Sunra-issued shape. * Anything that stores `id` and later looks it up in the *provider's own* dashboard, logs, or billing export — that value is still available, just no longer in the response body (see below). * `/v1/responses` callers using the returned `id` as `previous_response_id` for multi-turn conversations. This keeps working — the gateway translates transparently, see [above](#continuing-a-conversation-previous_response_id) — but stop assuming the value looks like `resp_...`; treat it as an opaque Sunra token. **What doesn't change:** * `x-sunra-prediction-id` has carried the Sunra-issued value since it shipped and is unaffected by this cutover — a caller who already reads the header sees the same value the body now also carries. * The upstream provider's id is still recorded and still retrievable, just relocated from the body to `provider_response_id` via [`GET /completions?completion_id=`](#looking-up-a-completion-later). **Reconciling across the cutover.** Whichever value you have on hand — the header, or a stored body `id` from before or after the switch — [`GET /completions?completion_id=`](#looking-up-a-completion-later) resolves it to the full completion record, including the upstream provider's own id. Use it to bridge historical data that was keyed on the old provider-native ids. **Timeline.** | Change | Status | | - | - | | `x-sunra-prediction-id` header; `error.prediction_id` / top-level `prediction_id` on error bodies | Shipped — non-breaking | | body `id` becomes the Sunra-issued value | Breaking — live since 2026-09-02 \~02:35 UTC | Treat `id` as an opaque, Sunra-owned token from here forward — stable in shape, not meaningful to parse — and use `GET /completions` whenever you need the value a specific upstream provider actually assigned. # Create a response Source: https://docs.sunra.ai/llm/responses POST https://api-llm.sunra.ai/v1/responses Creates a streaming or non-streaming response using the OpenAI Responses API format. Supports text, images, files, audio, video, function calling, web search, file search, code interpreter, reasoning, and more. ## Authentication **Authorization** `string` *required* Bearer token. Use your API key as the bearer token in the Authorization header. Format: `Bearer ` ## Request This endpoint expects an object. **model** `string` *required* Model ID used to generate the response. Browse available models at [sunra.ai/models](https://sunra.ai/models). **provider** `object` Optional provider routing preferences. Omit for automatic routing. See [Provider routing](/llm/provider-routing) for supported fields and provider discovery. **input** `string | object[]` Input for the response request. Can be a string or an array of input items including messages, function calls, function call outputs, reasoning items, and output messages. **input item types** **EasyInputMessage** **role** `string` *required* The role of the message author. Supported values: `user`, `assistant`, `system`, `developer`. **content** `string | object[]` The content of the message. Can be a string or an array of content parts. **content part types** **InputText** **type** `string` *required* Value: `input_text`. **text** `string` *required* The text content. **InputImage** **type** `string` *required* Value: `input_image`. **image\_url** `string | null` The URL of the image, or a base64-encoded data URI. **detail** `string` *required* Image detail level. Supported values: `auto`, `high`, `low`. **InputFile** **type** `string` *required* Value: `input_file`. **file\_id** `string | null` The ID of a previously uploaded file. **file\_data** `string` Base64-encoded file data. **filename** `string` The name of the file. **file\_url** `string` The URL of the file. **InputAudio** **type** `string` *required* Value: `input_audio`. **input\_audio** `object` *required* Audio input data. **properties** **data** `string` *required* Base64-encoded audio data. **format** `string` *required* Audio format. Supported values: `mp3`, `wav`. **InputVideo** **type** `string` *required* Value: `input_video`. **video\_url** `string` *required* A base64 data URL or remote URL that resolves to a video file. **type** `string` Value: `message`. Optional for easy input messages. **phase** `string` The phase of an assistant message. Supported values: `commentary`, `final_answer`. For follow-up requests, preserve and resend phase on all assistant messages. **InputMessageItem** **id** `string` *required* The ID of the message item. **type** `string` Value: `message`. **role** `string` *required* The role. Supported values: `user`, `system`, `developer`. **content** `object[] | null` Array of content parts (`input_text`, `input_image`, `input_file`, `input_audio`, `input_video`). **OutputMessage** **id** `string` *required* The ID of the output message. **type** `string` *required* Value: `message`. **role** `string` *required* Value: `assistant`. **status** `string` Status of the message. Supported values: `completed`, `incomplete`, `in_progress`. **content** `string | object[]` *required* Array of content items (`output_text`, `refusal`). **phase** `string` The phase. Supported values: `commentary`, `final_answer`. **FunctionCall** **type** `string` *required* Value: `function_call`. **id** `string` *required* The unique ID of the function call item. **call\_id** `string` *required* The call ID to match with the function call output. **name** `string` *required* The name of the function. **arguments** `string` *required* The arguments in JSON string format. **status** `string` Status. Supported values: `in_progress`, `completed`, `incomplete`. **FunctionCallOutput** **type** `string` *required* Value: `function_call_output`. **call\_id** `string` *required* The call ID of the function call being responded to. **output** `string | object[]` *required* The output of the function call. Can be a string or array of content parts (`input_text`, `input_image`, `input_file`). **id** `string | null` Optional ID for the output item. **status** `string` Status. Supported values: `in_progress`, `completed`, `incomplete`. **Reasoning** **type** `string` *required* Value: `reasoning`. **id** `string` *required* The unique ID of the reasoning item. **summary** `object[]` Array of reasoning summary text items, each with `type: "summary_text"` and `text`. **content** `object[] | null` Array of reasoning text content items, each with `type: "reasoning_text"` and `text`. **encrypted\_content** `string | null` Encrypted reasoning content for models that support it. **status** `string` Status. Supported values: `completed`, `incomplete`, `in_progress`. **signature** `string | null` A signature for the reasoning content, used for verification. **instructions** `string | null` Inserts a system (or developer) message as the first item in the model's context. When used with `input`, the instructions are inserted at the start of the input. **stream** `boolean` (default: false) If set to `true`, the response will be streamed using server-sent events (SSE). Streams are subject to an idle timeout and a lifetime ceiling — see [Output limits and stream lifetime](/llm/limits#stream-lifetime). A non-streaming request is terminated at the edge if the response takes too long to arrive, so any request that may generate for more than \~90 seconds must set `stream: true` — see [the edge timeout](/llm/limits#non-streaming-requests-the-edge-timeout). **max\_output\_tokens** `number | null` An upper bound for the number of output tokens, including visible output tokens and reasoning tokens. A value above the model's own ceiling is rejected with `400` rather than reduced. See [Output limits](/llm/limits). **temperature** `number | null` Sampling temperature between 0 and 2. Higher values increase randomness. For OpenAI reasoning models (`gpt-5.x`, `gpt-6-*`) with reasoning enabled, `temperature` and `top_p` are not supported upstream and are dropped silently by the gateway. **top\_p** `number | null` Nucleus sampling parameter. An alternative to sampling with temperature. **top\_k** `number` Sample only from the top K options for each subsequent token. Used to remove "long tail" low-probability responses. **frequency\_penalty** `number | null` Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text. **presence\_penalty** `number | null` Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they already appear in the text. **top\_logprobs** `integer | null` An integer specifying the number of most likely tokens to return at each token position. **max\_tool\_calls** `integer | null` Maximum number of tool calls the model can make in a single response. **tools** `object[]` An array of tools the model may call. **tool types** **Function** **type** `string` *required* Value: `function`. **name** `string` *required* The name of the function. **description** `string | null` A description of the function. **parameters** `object | null` A JSON Schema object defining the function parameters. **strict** `boolean | null` Whether strict schema adherence is enabled. **Web Search Preview** **type** `string` *required* Value: `web_search_preview` or `web_search_preview_2025_03_11`. **search\_context\_size** `string` Size of the search context. Supported values: `low`, `medium`, `high`. **user\_location** `object` User location information for search personalization. **properties** **type** `string` *required* Value: `approximate`. **city** `string | null` City name. **country** `string | null` Country name. **region** `string | null` Region/state name. **timezone** `string | null` IANA timezone name. **Web Search** **type** `string` *required* Value: `web_search` or `web_search_2025_08_26`. **search\_context\_size** `string` Size of the search context. Supported values: `low`, `medium`, `high`. **filters** `object | null` Domain filters for search results. **properties** **allowed\_domains** `string[] | null` List of allowed domains to restrict search results to. **user\_location** `object` User location information for search personalization. **File Search** **type** `string` *required* Value: `file_search`. **vector\_store\_ids** `string[]` *required* IDs of vector stores to search. **filters** `object` Filters for file search. Can be a comparison filter (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) or a compound filter (`and`, `or`). **max\_num\_results** `integer` Maximum number of results to return. **ranking\_options** `object` Ranking options for search results. **properties** **ranker** `string` Ranker to use. Supported values: `auto`, `default-2024-11-15`. **score\_threshold** `number` Minimum score threshold for results. **Computer Use Preview** **type** `string` *required* Value: `computer_use_preview`. **display\_height** `number` *required* Display height in pixels. **display\_width** `number` *required* Display width in pixels. **environment** `string` *required* The environment. Supported values: `windows`, `mac`, `linux`, `ubuntu`, `browser`. **Code Interpreter** **type** `string` *required* Value: `code_interpreter`. **container** `string | object` *required* Container configuration. Can be a container ID string or an object. **properties (when object)** **type** `string` *required* Value: `auto`. **file\_ids** `string[]` File IDs to make available in the container. **memory\_limit** `string | null` Memory limit. Supported values: `1g`, `4g`, `16g`, `64g`. **MCP** **type** `string` *required* Value: `mcp`. **server\_label** `string` *required* A label for the MCP server. **server\_url** `string` The URL of the MCP server. **allowed\_tools** `string[] | object` Tools the model is allowed to use from this server. **require\_approval** `string | object` Approval requirements for tool calls. String values: `always`, `never`. Can also be an object with `never` and `always` lists. **headers** `object | null` Custom headers to include in requests to the MCP server. **server\_description** `string` Description of the MCP server. **Image Generation** **type** `string` *required* Value: `image_generation`. **background** `string` Background type. Supported values: `transparent`, `opaque`, `auto`. **model** `string` Model to use. Supported values: `gpt-image-1`, `gpt-image-1-mini`. **quality** `string` Image quality. Supported values: `low`, `medium`, `high`, `auto`. **size** `string` Image size. Supported values: `1024x1024`, `1024x1536`, `1536x1024`, `auto`. **output\_format** `string` Output format. Supported values: `png`, `webp`, `jpeg`. **moderation** `string` Moderation level. Supported values: `auto`, `low`. **output\_compression** `number` Compression level for output. **partial\_images** `number` Number of partial images to return during generation. **tool\_choice** `string | object` Controls tool selection behavior. String values: `none`, `auto`, `required`. Can also specify a particular function or tool type. **object variants** **function** **type** `string` *required* Value: `function`. **name** `string` *required* The name of the function to use. **web\_search\_preview** **type** `string` *required* Value: `web_search_preview` or `web_search_preview_2025_03_11`. **parallel\_tool\_calls** `boolean | null` Whether to allow the model to run tool calls in parallel. **text** `object` Configuration for text response format. **properties** **format** `object` The text format configuration. **format types** **text** **type** `string` *required* Value: `text`. **json\_object** **type** `string` *required* Value: `json_object`. **json\_schema** **type** `string` *required* Value: `json_schema`. **name** `string` *required* The name of the response format. **schema** `object` *required* The JSON schema definition. **description** `string` Description of the schema. **strict** `boolean | null` Whether strict schema adherence is enabled. **verbosity** `string | null` Controls the verbosity of the text output. Supported values: `high`, `medium`, `low`. **reasoning** `object` Configuration for reasoning output. **properties** **effort** `string` Constrains effort on reasoning. Supported values: `xhigh`, `high`, `medium`, `low`, `minimal`, `none`. **summary** `string` Controls reasoning summary verbosity. Supported values: `auto`, `concise`, `detailed`. **max\_tokens** `number | null` Maximum number of tokens for reasoning. **enabled** `boolean | null` Whether reasoning is enabled. **thinking** `object` Cross-vendor switch for extended thinking. For OpenAI-family reasoning models (`gpt-5.x`, `gpt-6-*`) the gateway maps it to the reasoning effort: `{"type": "disabled"}` becomes `reasoning.effort: "none"`, `{"type": "enabled"}` leaves the model's default effort, and an explicit `reasoning.effort` (or `reasoning_effort`) always wins. **modalities** `string[]` Output modalities for the response. Supported values: `text`, `image`. **previous\_response\_id** `string | null` The ID of a previous response to use as context for this request — send the `id` Sunra returned for that response. An id Sunra does not recognize is rejected with `400 invalid_request_error`. See [Continuing a conversation](/llm/response-tracking#continuing-a-conversation-previous_response_id) for the transition period and failure behavior. **include** `string[]` Additional fields to include in the response. Supported values: `file_search_call.results`, `message.input_image.image_url`, `computer_call_output.output.image_url`, `reasoning.encrypted_content`, `code_interpreter_call.outputs`. **store** `boolean` Whether to store the generated response for later retrieval. **service\_tier** `string` The service tier to use for this request. Supported values: `auto`. **truncation** `string` Truncation strategy. Supported values: `auto`, `disabled`. **background** `boolean | null` Whether to run the request in the background. **metadata** `object` Set of key-value pairs that can be attached to the response. Keys must be ≤64 characters. Values must be ≤512 characters. Maximum 16 pairs allowed. **user** `string` A unique identifier representing your end-user. Maximum of 128 characters. ## Response Successful response object. **id** `string` Unique response identifier. Currently the upstream model provider's own id, forwarded as-is (e.g. `resp_...`). This is switching to a Sunra-issued value (breaking change, date to be announced): see [Response identifiers and tracking](/llm/response-tracking#the-id-field-is-switching-to-a-sunra-issued-value). The same id is always available via the [`x-sunra-prediction-id` response header](/llm/response-tracking#the-x-sunra-prediction-id-header), unaffected by that change. On streaming responses, the equivalent value is `response.id` on `response.created`, `response.completed`, and other response-carrying events. Use this id (not a raw upstream id) as `previous_response_id` to continue the conversation — see [Continuing a conversation](/llm/response-tracking#continuing-a-conversation-previous_response_id). **object** `string` The object type. Always `response`. **created\_at** `number` Unix timestamp (in seconds) of when the response was created. **completed\_at** `number | null` Unix timestamp (in seconds) of when the response completed. **status** `string` The status of the response. Possible values: `completed`, `incomplete`, `in_progress`, `failed`, `cancelled`, `queued`. **model** `string` The model used for generating the response. **output** `object[]` An array of output items generated by the model. **output item types** **OutputMessage** **type** `string` Value: `message`. **id** `string` The unique ID of the output message. **role** `string` Always `assistant`. **status** `string` Status of the message. Possible values: `completed`, `incomplete`, `in_progress`. **content** `object[]` The content of the output message. **content types** **OutputText** **type** `string` Value: `output_text`. **text** `string` The generated text content. **annotations** `object[]` Annotations for the content. Types include: * `file_citation`: `{type, file_id, filename, index}` * `url_citation`: `{type, url, title, start_index, end_index}` * `file_path`: `{type, file_id, index}` **logprobs** `object[]` Log probability information for output tokens. Each item contains `token`, `bytes`, `logprob`, and `top_logprobs`. **Refusal** **type** `string` Value: `refusal`. **refusal** `string` The refusal message. **phase** `string` The phase of the message. Possible values: `commentary`, `final_answer`. **Reasoning** **type** `string` Value: `reasoning`. **id** `string` The unique ID of the reasoning item. **content** `object[] | null` Array of reasoning text items, each with `type: "reasoning_text"` and `text`. **summary** `object[]` Array of reasoning summary items, each with `type: "summary_text"` and `text`. **encrypted\_content** `string | null` Encrypted reasoning content. **status** `string` Status. Possible values: `completed`, `incomplete`, `in_progress`. **signature** `string | null` A signature for the reasoning content, used for verification. **format** `string | null` The format of the reasoning content. Possible values: `unknown`, `openai-responses-v1`, `azure-openai-responses-v1`, `xai-responses-v1`, `anthropic-claude-v1`, `google-gemini-v1`. **FunctionCall** **type** `string` Value: `function_call`. **id** `string` The unique ID of the function call. **name** `string` The name of the function called. **arguments** `string` The arguments in JSON string format. **call\_id** `string` The call ID for matching with function call output. **status** `string` Status. Possible values: `completed`, `incomplete`, `in_progress`. **WebSearchCall** **type** `string` Value: `web_search_call`. **id** `string` The unique ID of the web search call. **action** `object` The search action. Types include: * `search`: `{type, query, queries?, sources?}` * `open_page`: `{type, url}` * `find_in_page`: `{type, pattern, url}` **status** `string` Status. Possible values: `completed`, `searching`, `in_progress`, `failed`. **FileSearchCall** **type** `string` Value: `file_search_call`. **id** `string` The unique ID of the file search call. **queries** `string[]` The search queries used. **status** `string` Status. Possible values: `completed`, `searching`, `in_progress`, `failed`. **ImageGenerationCall** **type** `string` Value: `image_generation_call`. **id** `string` The unique ID of the image generation call. **result** `string | null` The generated image data (base64). **status** `string` Status. Possible values: `in_progress`, `completed`, `generating`, `failed`. **output\_text** `string` Convenience field containing the concatenated text output from all output messages. **incomplete\_details** `object | null` Details about why the response is incomplete, if applicable. **properties** **reason** `string` The reason. Possible values: `max_output_tokens`, `content_filter`. **error** `object | null` An error object if the generation failed. **properties** **code** `string` Error code. Possible values: `server_error`, `rate_limit_exceeded`, `invalid_prompt`, `vector_store_timeout`, `invalid_image`, `invalid_image_format`, `invalid_base64_image`, `invalid_image_url`, `image_too_large`, `image_too_small`, `image_parse_error`, `image_content_policy_violation`, `invalid_image_mode`, `image_file_too_large`, `unsupported_image_media_type`, `empty_image_file`, `failed_to_download_image`, `image_file_not_found`. **message** `string` Human-readable error message. **usage** `object` Token usage statistics for the response. **properties** **input\_tokens** `number` The number of input tokens. **output\_tokens** `number` The number of output tokens. **total\_tokens** `number` The total number of tokens. **input\_tokens\_details** `object` Breakdown of input tokens. **properties** **cached\_tokens** `number` The number of cached tokens. **output\_tokens\_details** `object` Breakdown of output tokens. **properties** **reasoning\_tokens** `number` The number of reasoning tokens. **temperature** `number | null` The sampling temperature used. **top\_p** `number | null` The nucleus sampling value used. **max\_output\_tokens** `number | null` The max output tokens setting used. **top\_logprobs** `number` The top logprobs setting used. **max\_tool\_calls** `number | null` The max tool calls setting used. **presence\_penalty** `number | null` The presence penalty used. **frequency\_penalty** `number | null` The frequency penalty used. **instructions** `string | object[] | null` The instructions/system message used. **metadata** `object` The metadata attached to the response. **tools** `object[]` The tools configuration used. **tool\_choice** `string | object` The tool choice configuration used. **parallel\_tool\_calls** `boolean` Whether parallel tool calls was enabled. **reasoning** `object` The reasoning configuration used. **service\_tier** `string` The service tier used. Possible values: `auto`, `default`, `flex`, `priority`, `scale`. **store** `boolean` Whether the response was stored. **truncation** `string` The truncation strategy used. Possible values: `auto`, `disabled`. **text** `object` The text format configuration used. **previous\_response\_id** `string | null` The ID of the previous response used as context. **background** `boolean | null` Whether the request ran in the background. ```bash cURL theme={null} curl -X POST https://api-llm.sunra.ai/v1/responses \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "google/gemini-2.5-flash", "input": [ { "type": "message", "role": "user", "content": "Hello, how are you?" } ] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api-llm.sunra.ai/v1/responses", headers={ "Authorization": "Bearer ", "Content-Type": "application/json" }, json={ "model": "google/gemini-2.5-flash", "input": [ { "type": "message", "role": "user", "content": "Hello, how are you?" } ] } ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api-llm.sunra.ai/v1/responses", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json" }, body: JSON.stringify({ model: "google/gemini-2.5-flash", input: [ { type: "message", role: "user", content: "Hello, how are you?" } ] }) }); const data = await response.json(); console.log(data); ``` ```json 200 theme={null} { "id": "resp-abc123", "object": "response", "created_at": 1704067200, "completed_at": 1704067201, "status": "completed", "model": "google/gemini-2.5-flash", "output": [ { "type": "message", "id": "msg_abc123", "role": "assistant", "status": "completed", "content": [ { "type": "output_text", "text": "Hello! I'm doing well, thank you for asking. How can I help you today?", "annotations": [] } ] } ], "output_text": "Hello! I'm doing well, thank you for asking. How can I help you today?", "incomplete_details": null, "error": null, "temperature": 1.0, "top_p": 1.0, "max_output_tokens": null, "top_logprobs": 0, "presence_penalty": null, "frequency_penalty": null, "instructions": null, "metadata": {}, "tools": [], "tool_choice": "auto", "parallel_tool_calls": true, "reasoning": null, "service_tier": "auto", "store": true, "truncation": "disabled", "text": { "format": { "type": "text" } }, "usage": { "input_tokens": 15, "output_tokens": 18, "total_tokens": 33, "input_tokens_details": { "cached_tokens": 0 }, "output_tokens_details": { "reasoning_tokens": 0 } } } ``` # Token usage Source: https://docs.sunra.ai/llm/token-usage Every LLM response carries a `usage` object reporting the tokens the request consumed. Sunra reports usage in the semantics of the endpoint you called: a Messages response follows the Anthropic contract, and a Chat Completions or Responses response follows the OpenAI contract. These shapes differ, deliberately. An OpenAI SDK reading a Chat Completions response assumes OpenAI semantics, and an Anthropic SDK reading a Messages response assumes Anthropic semantics. Each endpoint honors what its own specification promises rather than being forced into one shared shape. | Endpoint | Prompt count field | Are cached tokens included in it? | | - | - | - | | `/v1/messages` | `input_tokens` | No — the three input buckets are mutually exclusive | | `/v1/chat/completions` | `prompt_tokens` | Yes — cached tokens are a subset, detailed in `prompt_tokens_details` | | `/v1/responses` | `input_tokens` | Yes — cached tokens are a subset, detailed in `input_tokens_details` | > [!WARNING] > > `/v1/messages` and `/v1/responses` both use the field name `input_tokens`, and it does not mean the same thing on both. On Messages it is fresh input only. On Responses it is the entire prompt, cached tokens included. ## Messages On `/v1/messages`, the three input buckets are **mutually exclusive**. No token is counted twice, so the prompt total is their sum: ``` total prompt = input_tokens + cache_creation_input_tokens + cache_read_input_tokens ``` **input\_tokens** `integer` Fresh input tokens only. Excludes both cache buckets. **cache\_creation\_input\_tokens** `integer` Tokens written to the cache by this request. **cache\_read\_input\_tokens** `integer` Tokens served to this request from the cache. **output\_tokens** `integer` Tokens generated by the model. **total\_tokens** `integer` The three input buckets plus `output_tokens`. Present on non-streaming responses. A bucket that is absent or `null` counts as zero. ### Worked example The same 19,000-token prefix sent twice against `claude-opus-4-8`. The first request writes the cache; the second reads it. ```json Cold request (cache write) theme={null} { "usage": { "input_tokens": 8, "cache_creation_input_tokens": 19349, "cache_read_input_tokens": 0, "output_tokens": 2, "total_tokens": 19359, "sunra_usage_semantics": "anthropic.exclusive.v1" } } ``` ```json Warm request (cache read) theme={null} { "usage": { "input_tokens": 8, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 19349, "output_tokens": 2, "total_tokens": 19359, "sunra_usage_semantics": "anthropic.exclusive.v1" } } ``` `input_tokens` stays at 8 across both requests, because 8 is the genuinely new input each time. The 19,349-token prefix moves from the creation bucket to the read bucket. Both requests sum to the same 19,359 total tokens but do not cost the same: cache writes and cache reads are priced at their own per-token rates, which is why they are reported as separate buckets. ## Chat Completions and Responses These endpoints report OpenAI semantics, unchanged. The prompt count is the **whole** prompt, and cached tokens are a **subset** of it reported separately. ```json /v1/chat/completions theme={null} { "usage": { "prompt_tokens": 19357, "completion_tokens": 2, "total_tokens": 19359, "prompt_tokens_details": { "cached_tokens": 19349 } } } ``` Summing `prompt_tokens` and `cached_tokens` double-counts. To get the fresh input on these endpoints, subtract: ``` fresh input = prompt_tokens - prompt_tokens_details.cached_tokens ``` The same rule applies to `/v1/responses` with `input_tokens` and `input_tokens_details.cached_tokens`. ## Prompt cache hits Many providers serve a repeated prompt prefix from a cache. Sunra reports those tokens in the shape of the endpoint you called, whatever the upstream provider calls them natively: | Endpoint | Where cache hits appear | | - | - | | `/v1/chat/completions` | `usage.prompt_tokens_details.cached_tokens` | | `/v1/responses` | `usage.input_tokens_details.cached_tokens` | | `/v1/messages` | `usage.cache_read_input_tokens` | Provider-native field names never reach you. DeepSeek, for example, reports `prompt_cache_hit_tokens` and `prompt_cache_miss_tokens` on its own API; through `/v1/chat/completions` the hit count arrives as `cached_tokens`, and the miss count is `prompt_tokens - cached_tokens`. Read the field for the endpoint you called and you do not need to know which provider served the request. Caching is a prefix match, not a whole-request match: changing the tail of a conversation while keeping the same system prompt still hits the cache for the prefix. Nothing needs to be enabled — providers that cache do so automatically. Cache hits are billed at the model's cache-read rate, which is far below its input rate, and cached tokens are never also charged at the input rate. See [Pricing](/platform/pricing#llm-token-pricing) for how a request with a cache hit settles. ## The `sunra_usage_semantics` marker Responses that Sunra has normalized carry a marker inside `usage`: ```json theme={null} "sunra_usage_semantics": "anthropic.exclusive.v1" ``` The marker is a promise about the **response**, not about any individual event or field. On a non-streaming response it appears in the root `usage` object; on a streamed response it appears in `message_start`, the event that carries the input buckets. When it is present with this value, the guarantees on this page hold: the three input buckets are mutually exclusive, and `total_tokens` — where present — is their sum plus `output_tokens`. **Its absence is also a promise.** Sunra normalizes only response shapes it has measured. Anything else is forwarded from the upstream provider untouched and left unmarked, and a response without the marker is one whose buckets Sunra does not vouch for. Assert on the marker rather than inspecting the numbers to guess which convention a response follows. Shape sniffing is what this field exists to replace — a heuristic such as "the buckets must be exclusive because they sum to more than the prompt count" is satisfied by more than one convention and will eventually read a response wrong. ```python theme={null} usage = response["usage"] if usage.get("sunra_usage_semantics") == "anthropic.exclusive.v1": total_prompt = ( usage["input_tokens"] + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0) ) else: # Not normalized by Sunra. Treat the upstream shape as unverified # and consult that provider's own documentation. total_prompt = None ``` Only `/v1/messages` responses ever carry the marker. Chat Completions and Responses follow the OpenAI specification and are not marked. The value is versioned. A breaking change to the meaning of these fields ships under a new value, so an equality check against `anthropic.exclusive.v1` will not silently start reading a different contract. ## Streaming On a streamed Messages request, usage arrives across two events. `message_start` carries the input side and the marker. The terminal `message_delta` carries `output_tokens` only. Assert on the marker in `message_start`, then merge the two events as usual — apply the later event's usage over the earlier one — to arrive at the values documented above. `total_tokens` is omitted from streamed responses. No single event knows both the input and output side, so any total computed mid-stream would be wrong. Sum the buckets yourself once the stream completes. ## Migrating from the previous behavior Sunra previously forwarded the upstream `usage` object verbatim on `/v1/messages`. The translation layer in front of some providers folds cache-creation tokens **into** `input_tokens`, so a caller who followed the Anthropic contract and summed the three buckets counted the cache-creation tokens twice. * **If you sum the three buckets**, as the Anthropic contract describes, you are now correct. No change is required on your side. * **If you compensated for the old behavior** — subtracting `cache_creation_input_tokens` out of `input_tokens` yourself, or otherwise reverse-engineering the fold — **stop**. That correction now subtracts tokens that were already excluded and will understate your input. * **If you read `total_tokens`**, it continues to report the full count: fresh input, both cache buckets, and output. Gate the change on the marker rather than on a deploy date, so the same code path is correct against both marked and unmarked responses. # Canonical Model Endpoints Source: https://docs.sunra.ai/multimodal/canonical-model-endpoints Use stable, provider-independent model URLs for image and video generation while keeping older provider-specific URLs compatible. # Canonical model endpoints Use a model's provider-independent URL in new integrations. When `provider` is omitted, Sunra keeps the model's existing automatic route: ``` POST https://api.sunra.ai/v1/queue/{owner}/{model}/{operation} ``` The following canonical models are available for GPT Image 2, Midjourney, and Kling: | Canonical model | Operations | | - | - | | `openai/gpt-image-2` | `text-to-image`, `image-editing` | | `midjourney/v7` | `text-to-image`, `image-blending`, `face-swap`, `image-editing`, `action`, `describe` | | `midjourney/v8.1` | `text-to-image`, `image-blending`, `face-swap`, `image-editing`, `action`, `describe` | | `kling/kling-v3-4k` | `text-to-video`, `image-to-video` | | `kling/kling-v3-omni-4k` | `text-to-video`, `image-to-video`, `reference-to-video` | For example: ```bash theme={null} curl -X POST "https://api.sunra.ai/v1/queue/openai/gpt-image-2/text-to-image" \ -H "Authorization: Bearer $SUNRA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"prompt":"a cinematic sunrise over a quiet harbor","quality":"high"}' ``` Model names that contain `preview` remain canonical version names. `Preview` identifies a model version, not a provider. ## Provider routing Discover the providers that can serve a canonical model and operation: ```bash theme={null} curl "https://api.sunra.ai/v1/models/google/gemini-2.5-flash-image/providers?operation=text-to-image" \ -H "Authorization: Bearer $SUNRA_API_KEY" ``` The response exposes only the provider ID and display name. Availability is scoped to the requested model, operation, organization access, enabled provider route, and endpoint price. To require one provider, add `provider.only` to the same canonical queue request: ```bash theme={null} curl -X POST "https://api.sunra.ai/v1/queue/google/gemini-2.5-flash-image/text-to-image" \ -H "Authorization: Bearer $SUNRA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "a cinematic sunrise over a quiet harbor", "provider": { "only": ["fal"] } }' ``` An unavailable explicit provider returns a 4xx error and is never silently replaced. The selected provider remains fixed for task retries, and billing uses that provider route's endpoint price. Omit `provider` to return to Automatic routing. JavaScript: ```ts theme={null} await sunra.subscribe("google/gemini-2.5-flash-image/text-to-image", { input: { prompt: "a cinematic sunrise over a quiet harbor" }, provider: { only: ["fal"] }, }) ``` Python: ```python theme={null} client.submit( "google/gemini-2.5-flash-image", {"prompt": "a cinematic sunrise over a quiet harbor"}, path="text-to-image", provider={"only": ["fal"]}, ) ``` ## Legacy compatibility Provider-specific model URLs remain callable for existing integrations, but no longer appear in the public model catalog. Their URL, input schema, response, fixed provider mapping, task lookup, pricing, and prediction identity are unchanged. A legacy URL cannot be switched to another provider. New integrations should use the canonical model in the right-hand column. | Legacy model URL | Canonical model for new integrations | | - | - | | `openai/gpt-image-2-azure` | `openai/gpt-image-2` | | `openai/gpt-image-2-reverse` | `openai/gpt-image-2` | | `midjourney/v7-reverse` | `midjourney/v7` | | `midjourney/v8.1-reverse` | `midjourney/v8.1` | | `kling/kling-v3-4k-fal` | `kling/kling-v3-4k` | | `kling/kling-v3-omni-4k-fal` | `kling/kling-v3-omni-4k` | | `bytedance/seedance-2.0-relay` | `bytedance/seedance-2.0` | | `bytedance/seedance-2.0-fast-relay` | `bytedance/seedance-2.0-fast` | | `google/gemini-2.5-flash-image-preview-fal` | `google/gemini-2.5-flash-image` | | `google/gemini-2.5-flash-image-preview-relay` | `google/gemini-2.5-flash-image` | | `google/gemini-3-pro-image-preview-fal` | `google/gemini-3-pro-image-preview` | | `google/gemini-3-pro-image-preview-relay` | `google/gemini-3-pro-image-preview` | | `google/gemini-3.1-flash-image-preview-fal` | `google/gemini-3.1-flash-image-preview` | | `google/gemini-3.1-flash-image-preview-relay` | `google/gemini-3.1-flash-image-preview` | | `google/veo3-1-fal` | `google/veo3-1` | | `google/veo3-1-fast-fal` | `google/veo3-1-fast` | | `google/veo3-1-lite-fal` | `google/veo3-1-lite` | If a legacy endpoint exposes provider-specific capabilities, you may keep using that legacy URL. Compatibility does not redirect the request to the canonical model. # ElevenLabs Voices Source: https://docs.sunra.ai/multimodal/elevenlabs-voices Discover the voices available in Sunra's shared ElevenLabs account, and use any of them in any ElevenLabs TTS model via custom_voice_id. # Listing ElevenLabs Voices Sunra exposes a lightweight helper endpoint that lets you discover the voices available on the shared ElevenLabs account that powers our ElevenLabs TTS models. The endpoint does not run an inference pipeline — there is no billing, no prediction record, no queue. It is a direct, paginated lookup against the upstream ElevenLabs voice library. Once you find a voice you like, plug its `voice_id` into the `custom_voice_id` field of any ElevenLabs TTS model and the model will use that voice instead of its default. ## Endpoint ``` GET https://api.sunra.ai/v1/elevenlabs/voices ``` Authentication is the same as the rest of the Sunra API — send your API key in the `Authorization` header. ## Query parameters All parameters are optional. | Parameter | Type / values | Default | Description | | - | - | - | - | | `page_size` | integer (max 100) | 10 | How many voices to return in one response. | | `search` | string | — | Filter by name, description, labels, or category. | | `sort_by` | `name` \| `date` | — | Field to sort by. `date` sorts by voice creation time. | | `sort_direction` | `asc` \| `desc` | — | Sort order. | | `category` | `premade` \| `cloned` \| `generated` \| `professional` | — | Voice category. | | `voice_type` | `personal` \| `community` \| `default` \| `workspace` \| `non-default` \| `non-community` \| `saved` | — | Voice ownership / source. | | `next_page_token` | string | — | Pagination token returned by a previous response. | ## Response ```json theme={null} { "voices": [ { "voice_id": "CwhRBWXzGAHq8TQ4Fs17", "name": "Roger", "settings": null }, { "voice_id": "JBFqnCBsd6RMkjVDRZzb", "name": "George", "settings": null } ], "has_more": true, "next_page_token": "RVhBVklUUXU0dnI0eG5TRHhNYUx8" } ``` | Field | Description | | - | - | | `voices[].voice_id` | The ID to pass as `custom_voice_id` to a TTS model. | | `voices[].name` | Human-readable voice name. | | `voices[].settings` | Default voice settings (`stability`, `similarity_boost`, etc.) or `null` if none are saved on the voice. | | `has_more` | `true` while more pages remain. | | `next_page_token` | Pass this as `next_page_token` in the next call to fetch the next page. `null` on the last page. | ## Pagination Drive pagination from `has_more` and `next_page_token`. Keep calling the endpoint with the previous response's `next_page_token` until `has_more` is `false`: ```javascript theme={null} let token = undefined; const all = []; do { const url = new URL("https://api.sunra.ai/v1/elevenlabs/voices"); url.searchParams.set("page_size", "100"); if (token) url.searchParams.set("next_page_token", token); const resp = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SUNRA_KEY}` }, }); const body = await resp.json(); all.push(...body.voices); token = body.has_more ? body.next_page_token : null; } while (token); ``` ## Examples ```bash cURL theme={null} curl -H "Authorization: Bearer $SUNRA_KEY" \ "https://api.sunra.ai/v1/elevenlabs/voices?category=premade&sort_by=date&sort_direction=desc&page_size=20" ``` ```javascript JavaScript theme={null} const resp = await fetch( "https://api.sunra.ai/v1/elevenlabs/voices?category=premade&sort_by=name&page_size=20", { headers: { Authorization: `Bearer ${process.env.SUNRA_KEY}` } } ); const { voices, has_more, next_page_token } = await resp.json(); console.log(voices.map((v) => `${v.name} (${v.voice_id})`)); ``` ```python Python theme={null} import os import requests resp = requests.get( "https://api.sunra.ai/v1/elevenlabs/voices", params={ "category": "premade", "sort_by": "name", "page_size": 20, }, headers={"Authorization": f"Bearer {os.environ['SUNRA_KEY']}"}, ) data = resp.json() for v in data["voices"]: print(v["name"], v["voice_id"]) ``` ## Using a voice in a TTS model Take any `voice_id` from the response and pass it as `custom_voice_id` when calling an ElevenLabs TTS endpoint. When `custom_voice_id` is set it overrides the model's default `voice` field, so you can use any voice from ElevenLabs's public voice library — premade voices, including the 21 exposed by the schema's `voice` enum, plus any other public-library voice ID you look up through this endpoint. Voices cloned or saved privately into a specific ElevenLabs account are not available by default. ### `voice` vs `custom_voice_id` The `voice` enum is a shortcut for the voices the shared account holds — the same set this endpoint lists, by name instead of by id, identical across all four TTS endpoints. It moves when the account's library moves: ElevenLabs retires voices and re-points their ids, so a name that is gone from the enum is a name the account can no longer serve under that id. `Aria`, `Charlotte` and `Rachel` were removed for exactly that reason — their ids now resolve to unrelated replacement voices upstream. If you were passing one of those names, pick another from the enum, or pass the id you want as `custom_voice_id`, which is never filtered against the enum. This works for every Sunra ElevenLabs TTS endpoint: * `elevenlabs/eleven-v3/text-to-speech` * `elevenlabs/multilingual-v2/text-to-speech` * `elevenlabs/turbo-v2.5/text-to-speech` * `elevenlabs/multilingual-sts-v2/speech-to-speech` ```javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe( "elevenlabs/multilingual-v2/text-to-speech", { input: { text: "Welcome to Sunra.", custom_voice_id: "JBFqnCBsd6RMkjVDRZzb", }, } ); ``` ```python theme={null} import sunra_client result = sunra_client.subscribe( "elevenlabs/multilingual-v2/text-to-speech", arguments={ "text": "Welcome to Sunra.", "custom_voice_id": "JBFqnCBsd6RMkjVDRZzb", }, ) ``` # Image to Video Source: https://docs.sunra.ai/multimodal/image-to-video ### How to Generate Videos using the sunra API sunra offers a simple and easy-to-use API that allows you to generate videos from your images using pre-trained models. This endpoint is perfect for creating video clips from your images for various use cases such as social media, marketing, and more. Here is an example of how to generate videos using the sunra API: ```javascript Javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("kling/kling-v2-master/image-to-video", { input: { prompt: 'A cute hamster lies leisurely on a lifebuoy, wearing fashionable sunglasses, and drifts with the gentle waves on the shimmering sea surface. The hamster reclines comfortably, enjoying a peaceful and pleasant time. Cartoon style, the camera follows the subject moving, with a heartwarming and high picture quality.', negative_prompt: '', guidance_scale: 0.5, aspect_ratio: '16:9', duration: 5, start_image: 'https://assets.sunra.ai/uploads/1748811753168-05ceab0d.png' }, logs: true, onQueueUpdate: (update) => { console.log(update) }, }); console.log(result.data); console.log(result.requestId); ``` ```python Python theme={null} import sunra_client result = sunra_client.subscribe( "kling/kling-v2-master/image-to-video", arguments={ "prompt": "A cute hamster lies leisurely on a lifebuoy, wearing fashionable sunglasses, and drifts with the gentle waves on the shimmering sea surface. The hamster reclines comfortably, enjoying a peaceful and pleasant time. Cartoon style, the camera follows the subject moving, with a heartwarming and high picture quality.", "guidance_scale": 0.5, "aspect_ratio": "16:9", "duration": 5, "start_image": "https://assets.sunra.ai/uploads/1748811753168-05ceab0d.png" }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` ```java Java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "A cute hamster lies leisurely on a lifebuoy, wearing fashionable sunglasses, and drifts with the gentle waves on the shimmering sea surface. The hamster reclines comfortably, enjoying a peaceful and pleasant time. Cartoon style, the camera follows the subject moving, with a heartwarming and high picture quality.", "guidance_scale", 0.5, "aspect_ratio", "16:9", "duration", 5, "start_image", "https://assets.sunra.ai/uploads/1748811753168-05ceab0d.png" ); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); Output response = client.subscribe("kling/kling-v2-master/image-to-video", options); System.out.println("Completed!"); System.out.println(response.getData()); ``` ```bash Curl theme={null} curl --request POST \ --url https://api.sunra.ai/v1/queue/kling/kling-v2-master/image-to-video \ --header "Authorization: Key $SUNRA_KEY" \ --header "Content-Type: application/json" \ --data '{"prompt":"A cute hamster lies leisurely on a lifebuoy, wearing fashionable sunglasses, and drifts with the gentle waves on the shimmering sea surface. The hamster reclines comfortably, enjoying a peaceful and pleasant time. Cartoon style, the camera follows the subject moving, with a heartwarming and high picture quality.","negative_prompt":"","guidance_scale":0.5,"aspect_ratio":"16:9","duration":5,"start_image":"https://assets.sunra.ai/uploads/1748811753168-05ceab0d.png"}' ``` ### How to select the model to use sunra offers a variety of video generation models. You can select the model that best fits your needs based on the style and quality of the videos you want to generate. Here are some of the available models: * [kling/kling-v2-master/image-to-video](https://sunra.ai/models/kling/kling-v2-master/image-to-video): Kling V2 Master is designed to improve prompt adherence, motion dynamics, and visual aesthetics. * [minimax/i2v-01/image-to-video](https://sunra.ai/models/sunra-ai/minimax-video/image-to-video): Generates 6-second HD videos from images and text prompts To select a model, simply specify the model ID in the subscribe method as shown in the example above. You can find more models and their descriptions in the [Image to Video Models](https://sunra.ai/models?modalities%5B0%5D=modality-image-video) page. # MCP Server Source: https://docs.sunra.ai/multimodal/mcp-server-setup Learn how to integrate SUNRA's MCP server with Cursor, VS Code, Cline, Windsurf, and other development environments for seamless AI model access. # Setting Up SUNRA MCP Server The SUNRA Model Context Protocol (MCP) server provides seamless integration with popular development environments, allowing you to access SUNRA's AI models directly from your editor or IDE. This guide will walk you through setting up the MCP server with various tools. ## What is MCP? Model Context Protocol (MCP) is a standardized way for AI assistants to securely access external resources and tools. The SUNRA MCP server allows you to: * **List and search AI models** available on SUNRA * **Submit requests** to any model endpoint * **Check status** and retrieve results from the queue system * **Upload files** and manage model schemas * **Manage API authentication** seamlessly ## Prerequisites Before setting up the MCP server, ensure you have: 1. **Node.js** installed (version 18 or higher) 2. **SUNRA API key** from [your dashboard](https://sunra.ai/dashboard/api-tokens) 3. Your preferred editor/IDE installed ## Installation The SUNRA MCP server is available as an npm package: ```bash theme={null} npm install -g @sunra/mcp-server ``` Or use it directly with npx (recommended): ```bash theme={null} npx @sunra/mcp-server ``` ## Configuration by Editor ### Cursor Cursor supports MCP through its configuration file. Create or update `.cursor/mcp.json` in your project root: ```json theme={null} { "mcpServers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"] } } } ``` **Alternative global configuration** in your user settings: 1. Open Cursor Settings 2. Navigate to "MCP Servers" 3. Add a new server with: * **Name**: `sunra-mcp-server` * **Command**: `npx` * **Args**: `@sunra/mcp-server` ### VS Code For VS Code with MCP support (requires compatible extension): Create `.vscode/mcp.json`: ```json theme={null} { "mcpServers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"], "env": { "SUNRA_KEY": "your-api-key-here" } } } } ``` ### Cline Cline supports MCP servers through its settings. Add to your Cline configuration: ```json theme={null} { "mcpServers": { "sunra": { "command": "npx", "args": ["@sunra/mcp-server"], "description": "SUNRA AI model access" } } } ``` ### Windsurf For Windsurf IDE, configure MCP in the workspace settings: ```json theme={null} { "mcp": { "servers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"], "timeout": 30000 } } } } ``` ### Claude Desktop Add to your Claude Desktop configuration file: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` ```json theme={null} { "mcpServers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"] } } } ``` ### Other MCP-Compatible Tools For any other tool that supports MCP, use this general configuration pattern: ```json theme={null} { "mcpServers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"], "env": { "SUNRA_KEY": "${SUNRA_KEY}" } } } } ``` ## Environment Setup ### Setting Your API Key You can configure your SUNRA API key in several ways: #### Option 1: Environment Variable (Recommended) ```bash theme={null} export SUNRA_KEY="your-api-key-here" ``` For Windows: ```cmd theme={null} set SUNRA_KEY=your-api-key-here ``` #### Option 2: Configuration File Some editors allow you to set environment variables directly in the MCP configuration: ```json theme={null} { "mcpServers": { "sunra-mcp-server": { "command": "npx", "args": ["@sunra/mcp-server"], "env": { "SUNRA_KEY": "your-api-key-here" } } } } ``` #### Option 3: Runtime Configuration The MCP server also supports setting the API key at runtime using the `set-sunra-key` tool. ## Available MCP Tools Once configured, you'll have access to these tools through your AI assistant: ### Model Management * `list-models` - Browse all available AI models * `search-models` - Find models by keywords * `model-schema` - Get input/output schemas for specific models ### Request Management * `submit` - Submit requests to model endpoints * `status` - Check request status and logs * `result` - Retrieve completed results * `cancel` - Cancel running requests * `subscribe` - Submit and wait for completion ### File Management * `upload` - Upload files to SUNRA storage ## Usage Examples ### Listing Available Models ``` Use the list-models tool to show me what AI models are available. ``` ### Generating an Image ``` Use the submit tool to generate an image with the black-forest-labs/flux-1.1-pro/text-to-image endpoint. Use the prompt: "A serene mountain landscape at sunset" ``` ### Checking Request Status ``` Check the status of request ID: pd_xxxxxx ``` ## Troubleshooting ### Common Issues **MCP Server Not Found** * Ensure Node.js is installed and accessible * Try installing globally: `npm install -g @sunra/mcp-server` * Verify the command path is correct **Authentication Errors** * Check that your SUNRA\_KEY environment variable is set * Verify your API key is valid at [SUNRA Dashboard](https://sunra.ai/dashboard/api-tokens) * Try setting the key using the `set-sunra-key` tool **Connection Timeouts** * Increase timeout values in your configuration * Check your internet connection * Verify SUNRA API status **Permission Errors** * Ensure proper file permissions for configuration files * Try running with appropriate user permissions ### Getting Help If you encounter issues: 1. Check the [SUNRA Documentation](https://docs.sunra.ai) 2. Review your editor's MCP documentation 3. Raise an issue on [GitHub](https://github.com/sunra-ai/sunra-clients/issues) ## Next Steps Once your MCP server is configured: 1. **Explore Models**: Use `list-models` to see all available AI capabilities 2. **Try Examples**: Start with simple text-to-image or text-to-video generations 3. **Build Workflows**: Combine multiple models for complex AI pipelines 4. **Monitor Usage**: Track your API usage in the [SUNRA Dashboard](https://sunra.ai/dashboard/) The MCP integration makes it easy to incorporate powerful AI models directly into your development workflow, enabling rapid prototyping and seamless AI-powered features in your applications. # Queue Source: https://docs.sunra.ai/multimodal/queue For requests that take longer than a few seconds, typical in AI applications, we've developed a queue system. This system offers you fine-grained control to manage traffic surges, cancel requests if needed, and monitor your request's status in the queue. It also eliminates the need for handling long-running HTTP requests. ## Queue Endpoints You can access all queue features through the following endpoints: | Endpoint | Method | Description | | - | - | - | | api.sunra.ai/v1/queue/\{model-id} | POST | Adds a request to the queue | | api.sunra.ai/v1/queue/requests/\{request\_id}/status | GET | Retrieves the status of a request | | api.sunra.ai/v1/queue/requests/\{request\_id}/status/stream | GET | Streams the status until completion | | api.sunra.ai/v1/queue/requests/\{request\_id} | GET | Fetches the response of a request | | api.sunra.ai/v1/queue/requests/\{request\_id}/cancel | PUT | Cancels a request | For example, to submit a request using curl and add it to the queue: ```bash theme={null} curl -X POST \ https://api.sunra.ai/v1/queue/black-forest-labs/flux-1.1-pro/text-to-image \ -H "Authorization: Key $SUNRA_KEY" \ -d '{"prompt": "A Studio Ghibli-inspired seaside town with colorful houses, laundry flapping, and cats sleeping on windowsills."}' ``` Here's a sample response including the `request_id`: ```json theme={null} { "request_id": "pd_vXW7VwPN2MbTwT8bzpWrYU5Y", "response_url": "https://api.sunra.ai/v1/queue/requests/pd_vXW7VwPN2MbTwT8bzpWrYU5Y", "status_url": "https://api.sunra.ai/v1/queue/requests/pd_vXW7VwPN2MbTwT8bzpWrYU5Y/status", "cancel_url": "https://api.sunra.ai/v1/queue/requests/pd_vXW7VwPN2MbTwT8bzpWrYU5Y/cancel" } ``` The payload includes the `request_id` and provides URLs for checking status, canceling, or retrieving the response, streamlining your workflow without additional endpoint development. ## Request Status To monitor the progress of your request, use the provided endpoint with your unique request ID. This allows you to track the status, queue position, or retrieve the response once it's ready. ### Endpoint Usage ```bash theme={null} curl -X GET https://api.sunra.ai/v1/queue/requests/{request_id}/status ``` ## Example Response When your request is in the queue, you'll receive a response like this: ```json theme={null} { "status": "IN_QUEUE", "metrics": {}, "queue_position": 0, "response_url": "https://api.sunra.ai/v1/queue/requests/pd_hvTNHJPSZj4KgtzytfTGsySf", "status_url": "https://api.sunra.ai/v1/queue/requests/pd_hvTNHJPSZj4KgtzytfTGsySf/status", "cancel_url": "https://api.sunra.ai/v1/queue/requests/pd_hvTNHJPSZj4KgtzytfTGsySf/cancel" } ``` ## Possible Statuses Your request can be in one of three states: * **IN\_QUEUE**: Indicates the request is waiting to be processed. * `queue_position`: Shows your place in the queue. * `response_url`: URL for retrieving the response once processing completes. * **IN\_PROGRESS**: The request is currently being processed. * `logs`: Detailed logs (if enabled) showing processing steps. * `response_url`: Where the final response will be available. * **COMPLETED**: Processing has finished. * `logs`: Logs detailing the entire process. * `response_url`: Direct link to your completed response. * `success`: `true` when the prediction produced an output, `false` when it failed. * `error`: the failure object when `success` is `false`, and `null` otherwise. It is the same object every other channel publishes — see [Prediction failure codes](/platform/errors#prediction-failure-codes), and the [migration note](/platform/errors#migration-note-error-is-null-when-there-is-no-error) if you integrated before 17 August 2026. ## Enabling Logs Logs provide insights into request processing. They are disabled by default but can be enabled with a query parameter: ```bash theme={null} curl -X GET https://api.sunra.ai/v1/queue/requests/{request_id}/status?logs=1 ``` Each log entry includes: * `message`: Description of the event. * `level`: Severity (e.g., INFO, ERROR). * `source`: Origin of the log. * `timestamp`: Time the log was generated. ## Real-Time Monitoring For continuous updates, use the streaming endpoint: ```bash theme={null} curl -X GET https://api.sunra.ai/v1/queue/requests/{request_id}/status/stream ``` This provides real-time status updates in `text/event-stream` format until the request is completed. ## Webhooks If you'd rather be notified than poll, pass a `webhook` query parameter when you submit a request. Sunra will `POST` the final result to that URL once the request reaches a terminal state, so you don't need to keep a connection open or schedule polling. ### Enabling Webhooks Append a URL-encoded `webhook` query parameter to the submit endpoint: ```bash theme={null} curl -X POST \ "https://api.sunra.ai/v1/queue/black-forest-labs/flux-1.1-pro/text-to-image?webhook=https%3A%2F%2Fexample.com%2Fsunra-webhook" \ -H "Authorization: Key $SUNRA_KEY" \ -d '{"prompt": "A Studio Ghibli-inspired seaside town with colorful houses, laundry flapping, and cats sleeping on windowsills."}' ``` URL-encode the webhook URL (e.g. `https%3A%2F%2F...`) and make sure it is reachable from Sunra; HTTPS is strongly recommended. The submit response is unchanged — you still receive a `request_id` and the same status/cancel/response URLs. ### When the Webhook Fires Sunra calls your webhook **only on terminal events**: * `succeeded` — the request finished and the output is included in the payload. * `failed` — the request errored and the error details are included in the payload. Intermediate states (`IN_QUEUE`, `IN_PROGRESS`) do **not** trigger a webhook. If you also need progress updates, combine the webhook with the streaming or polling endpoints described above. ### Request Format Sunra sends a `POST` request to your webhook URL with: | Header | Value | | - | - | | `Content-Type` | `application/json` | | `User-Agent` | `Sunra-AI-Webhook/1.0` | Your endpoint must respond with a `2xx` status code within 5 seconds. Acknowledge fast and offload any heavy work to a background job — slow responses are treated as failures and trigger retries. ### Payload A successful event: ```json theme={null} { "id": "pd_vXW7VwPN2MbTwT8bzpWrYU5Y", "object": "prediction", "model": "black-forest-labs/flux-1.1-pro", "model_endpoint": "text-to-image", "status": "succeeded", "input": { "prompt": "A Studio Ghibli-inspired seaside town..." }, "output": { "images": [ { "url": "https://..." } ] }, "created_at": "2026-04-28T12:00:00.000Z", "completed_at": "2026-04-28T12:00:08.123Z" } ``` A failed event swaps `output` for `error`. Read `retryable` to decide whether to retry, and `reason` for the specific cause — see [Prediction failure codes](/platform/errors#prediction-failure-codes) for the full vocabulary. Both are optional and both are open sets, so tolerate values you do not recognise: ```json theme={null} { "id": "pd_vXW7VwPN2MbTwT8bzpWrYU5Y", "object": "prediction", "model": "black-forest-labs/flux-1.1-pro", "model_endpoint": "text-to-image", "status": "failed", "input": { "prompt": "A Studio Ghibli-inspired seaside town..." }, "error": { "code": "unsafe_content", "reason": "moderation_blocked", "retryable": false, "message": "The prediction may contain sensitive or restricted content and has been suppressed. Please revise your input." }, "created_at": "2026-04-28T12:00:00.000Z", "completed_at": "2026-04-28T12:00:08.123Z" } ``` | Field | Description | | - | - | | `id` | The `request_id` returned at submit time. Use it to deduplicate retries. | | `object` | Always `"prediction"`. | | `model` | The model owner and name (e.g. `black-forest-labs/flux-1.1-pro`). | | `model_endpoint` | The endpoint slug (e.g. `text-to-image`). | | `status` | `"succeeded"` or `"failed"`. | | `input` | The original request body you submitted. | | `output` | Present on `succeeded`. The same response body returned by the result endpoint. | | `error` | Present on `failed`, and absent — not `null` — on `succeeded`. Carries a machine-readable `code`, an optional fine-grained `reason`, an optional `retryable` flag and a human-readable `message` — see [Prediction failure codes](/platform/errors#prediction-failure-codes). | | `created_at` | ISO 8601 timestamp of when the request was created. | | `completed_at` | ISO 8601 timestamp of when the request reached the terminal state. | ### Retry Behavior Webhook delivery is at-least-once. If your endpoint times out (5s) or returns a non-`2xx` status, Sunra retries with exponential backoff — up to **3 retries** with delays starting at 10 seconds and capped at 30 seconds. After the final retry, the failure is logged and no further attempts are made. Because retries can deliver the same event more than once, your handler should be idempotent — use the `id` field as the dedupe key. ### Best Practices * Use HTTPS and validate that traffic is reaching the endpoint you expect. * Respond `2xx` immediately and process the payload asynchronously. * Treat delivery as at-least-once and dedupe by `id`. * Combine webhooks with the polling or streaming endpoints if you need progress updates before the terminal event. ## Cancelling Requests If your request is still queued, you can cancel it with: ```bash theme={null} curl -X PUT https://api.sunra.ai/v1/queue/requests/{request_id}/cancel ``` ## Retrieving Responses Once your request is `COMPLETED`, retrieve the response using: ```bash theme={null} curl -X GET https://api.sunra.ai/v1/queue/requests/{request_id} ``` This endpoint also provides logs for review. ## Simplified Integration with Sunra Client The Sunra client automates status tracking, simplifying app development with Sunra functions. ## Rate Limits To ensure fair usage and system stability, our API endpoints are subject to the following rate limits: | Endpoint Type | Rate Limit | Burst Limit | | :- | :- | :- | | **Submit to Queue** | 10 requests/second | 100 requests/minute | | **All Other Endpoints** | 100 requests/second | 1,800 requests/minute | If you exceed these limits, you will receive a `403 Forbidden` response. We recommend implementing a retry mechanism with exponential backoff to handle these cases. # Quickstart Source: https://docs.sunra.ai/multimodal/quickstart In this guide, we'll walk you through using one of our popular model endpoints, such as [black-forest-labs/flux-kontext-pro/text-to-image](https://sunra.ai/models/black-forest-labs/flux-kontext-pro/text-to-image). Before diving in, you'll need an API key — see [Authentication](/platform/authentication) for how to create one and configure your environment. Choose your preferred programming language below to get started: ## JavaScript/Node.js To get started, install the client package and configure it with your API key: ```bash npm theme={null} npm install @sunra/client ``` ```bash pnpm theme={null} pnpm add @sunra/client ``` ```bash yarn theme={null} yarn add @sunra/client ``` Set your API key as an environment variable: ```bash theme={null} export SUNRA_KEY="your-api-key-here" ``` Once configured, you can invoke our Model API endpoint using the sunra [client](/client-libraries/javascript): ```javascript theme={null} import { sunra } from "@sunra/client"; // Optionally, configure the client with a different API key other than the one set in the environment variable sunra.config({ credentials: "YOUR_SUNRA_KEY", }); const result = await sunra.subscribe("black-forest-labs/flux-kontext-pro/text-to-image", { input: { prompt: "A rabbit wearing glasses reading a book under a mushroom in watercolor style.", width: 1024, height: 768, output_format: "jpeg" }, }); ``` ## Python Install the Python client library using pip: ```bash theme={null} pip install sunra-client ``` Set your API key as an environment variable: ```bash theme={null} export SUNRA_KEY="your-api-key-here" ``` Configure and use the client: ```python theme={null} import sunra_client result = sunra_client.subscribe( "black-forest-labs/flux-kontext-pro/text-to-image", arguments={ "prompt": "A rabbit wearing glasses reading a book under a mushroom in watercolor style.", "width": 1024, "height": 768, "output_format": "jpeg" }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` ## Java Add the Java client library to your project using your preferred build system: ```kotlin Gradle theme={null} implementation("ai.sunra.client:sunra-client:0.1.5") ``` ```xml Maven theme={null} ai.sunra.client sunra-client 0.1.5 ``` Set your API key as an environment variable: ```bash theme={null} export SUNRA_KEY="your-api-key-here" ``` Configure and use the client: ```java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "A rabbit wearing glasses reading a book under a mushroom in watercolor style.", "width", 1024, "height", 768, "output_format", "jpeg" ); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); Output response = client.subscribe("black-forest-labs/flux-kontext-pro/text-to-image", options); System.out.println("Completed!"); System.out.println(response.getData()); ``` ## Next Steps We offer various models like Flux kontext pro and Kling v2 master as ready-to-use APIs. Explore these on our [Model Playground](https://sunra.ai/models). To use a model, visit its "API" tab to find the URL, source code, and usage examples, helping you integrate it seamlessly into your applications. For more detailed information about each client library, visit our [client libraries documentation](/client-libraries). # Server-Side Integration Source: https://docs.sunra.ai/multimodal/server-side-integration While Sunra's API endpoints can be accessed directly from client-side code, storing API keys in such environments poses significant security risks. To address this, developers typically create custom server-side APIs that securely interact with third-party services like Sunra before returning results to the client. Although effective, this approach often distracts teams from focusing on their core business objectives. To streamline this process, we've developed client libraries that support a proxy mode. This feature enables you to utilize the client libraries in your frontend code while maintaining API keys securely within your server-side infrastructure. ### Pre-Built Proxy Solutions We offer pre-built proxy solutions for several popular frameworks: * Node.js with Next.js: a Next.js API route handler that can be used in any Next.js app. It supports both [Page](https://github.com/sunra-ai/sunra-clients/tree/main/examples/demo-nextjs-page-router) and [App](https://github.com/sunra-ai/sunra-clients/tree/main/examples/demo-nextjs-app-router) routers. We use it ourselves in all of our apps in production. * [Node.js with Express](https://github.com/sunra-ai/sunra-clients/tree/main/examples/demo-express-app): an Express route handler that can be used in any Express app. You can also implement custom logic and compose together with your own handlers. We are committed to expanding our offerings based on community feedback. If you have specific requests or suggestions, please [contact us](https://sunra.ai/contact) to share your thoughts. ### Setting Up the Proxy To integrate the proxy functionality, configure your client by specifying the `proxyUrl` in your settings: ```javascript theme={null} import { sunra } from "@sunra/client"; sunra.config({ proxyUrl: "/api/sunra/proxy", }); ``` This setup ensures that API calls are routed through your server, enhancing security and simplifying development. # Speech to Text Source: https://docs.sunra.ai/multimodal/speech-to-text To convert speech to text using the sunra API, you need to send a request to the appropriate endpoint with the required input parameters. The API leverages pre-trained models to transcribe audio files into text, enabling seamless conversion by simply providing an audio file. Here's how you can use the sunra API for speech-to-text conversion: ```javascript Javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("elevenlabs/scribe-v1/speech-to-text", { input: { audio: 'https://assets.sunra.ai/uploads/1749243418768-74d68e25.wav', language: 'English', tag_audio_events: true, speaker_diarization: false }, logs: true, onQueueUpdate: (update) => { console.log(update) }, }); console.log(result.data); console.log(result.requestId); ``` ```python Python theme={null} import sunra_client result = sunra_client.subscribe( "elevenlabs/scribe-v1/speech-to-text", arguments={ "audio": "https://assets.sunra.ai/uploads/1749243418768-74d68e25.wav", "language": "English", "tag_audio_events": True, "speaker_diarization": False }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` ```java Java theme={null} import ai.sunra.client.*; import java.util.Map; import com.google.gson.JsonObject; var client = SunraClient.withEnvCredentials(); var response = client.subscribe( "elevenlabs/scribe-v1/speech-to-text", SubscribeOptions.builder() .input(Map.of( "audio", "https://assets.sunra.ai/uploads/1749243418768-74d68e25.wav", "language", "English", "tag_audio_events", true, "speaker_diarization", false)) .resultType(JsonObject.class) .onQueueUpdate(update -> System.out.printf( "\nStatus Update: %s, Request ID: %s%n", update.getStatus(), update.getRequestId() )) .logs(true) .build() ); System.out.println("Completed!"); System.out.println(response.getData()); ``` ```bash Curl theme={null} curl --request POST \ --url https://api.sunra.ai/v1/queue/elevenlabs/scribe-v1/speech-to-text \ --header "Authorization: Key $SUNRA_KEY" \ --header "Content-Type: application/json" \ --data '{"audio":"https://assets.sunra.ai/uploads/1749243418768-74d68e25.wav","language":"English","tag_audio_events":true,"speaker_diarization":false}' ``` ### Choosing the Right Model sunra provides a range of speech-to-text models to suit different needs. Select a model based on your requirements for accuracy and performance. Here are some available options: * **[elevenlabs/scribe-v1](https://sunra.ai/models/elevenlabs/scribe-v1)**: 99-language speech-to-text with word-level timestamps and diarisation—the company’s most accurate ASR yet. To use a specific model, specify its ID in the `subscribe` method as shown in the example. For more models and details, visit the [Speech to Text Models](https://sunra.ai/models?modalities%5B0%5D=modality-audio-text) page. # Text to Image Source: https://docs.sunra.ai/multimodal/text-to-image ### How to Generate Images using the sunra API To create images with the sunra API, send a request to the specified endpoint including your input parameters. The API leverages pre-trained models to generate images based on text prompts, enabling you to produce visuals by describing them in natural language. Here's an example of generating an image from text using the sunra API: ```javascript Javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("black-forest-labs/flux-kontext-max/text-to-image", { input: { prompt: 'A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.', prompt_enhancer: true, aspect_ratio: '16:9', output_format: 'jpeg', safety_tolerance: 2 }, logs: true, onQueueUpdate: (update) => { console.log(update) }, }); console.log(result.data); console.log(result.requestId); ``` ```python Python theme={null} import sunra_client result = sunra_client.subscribe( "black-forest-labs/flux-kontext-max/text-to-image", arguments={ "prompt": "A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.", "prompt_enhancer": True, "aspect_ratio": "16:9", "output_format": "jpeg", "safety_tolerance": 2 }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` ```java Java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.", "prompt_enhancer", true, "aspect_ratio", "16:9", "output_format", "jpeg", "safety_tolerance", 2 ); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); Output response = client.subscribe("black-forest-labs/flux-kontext-max/text-to-image", options); System.out.println("Completed!"); System.out.println(response.getData()); ``` ```bash Curl theme={null} curl --request POST \ --url https://api.sunra.ai/v1/queue/black-forest-labs/flux-kontext-max/text-to-image \ --header "Authorization: Key $SUNRA_KEY" \ --header "Content-Type: application/json" \ --data '{"prompt":"A glass teapot with blooming flower tea inside, placed on a wooden table by a sunlit window with gentle morning light.","prompt_enhancer":true,"aspect_ratio":"16:9","output_format":"jpeg","safety_tolerance":2}' ``` ### How to Select the Model sunra provides various image generation models. Choose one that suits your needs based on desired style and quality. Available models include: * **[black-forest-labs/flux-kontext-pro](https://sunra.ai/models/black-forest-labs/flux-kontext-pro)**: A unified model delivering local editing, generative modifications, and text-to-image generation in FLUX.1 quality. Processes text and image inputs for precise regional edits or full scene transformations at breakthrough speeds, pioneering iterative workflows that maintain character consistency across multiple editing turns. * **[black-forest-labs/flux-1.1-pro](https://sunra.ai/models/black-forest-labs/flux-1.1-pro)**: The best of FLUX, offering state-of-the-art performance image generation at blazing speeds with top of the line prompt following, visual quality, image detail, and output diversity. * **[google-deepmind/imagen4](https://sunra.ai/models/google-deepmind/imagen4)**: Imagen 4 generates photorealistic 2K images with fine detail, accurate text, and multilingual prompt support. To select a model, specify its ID in the subscribe method as shown. Explore more models on the [Text to Image Models](https://sunra.ai/models?modalities%5B0%5D=modality-text-image) page. # Text to Video Source: https://docs.sunra.ai/multimodal/text-to-video ### How to Generate Videos using the sunra API sunra offers a simple and easy-to-use API that allows you to generate videos from your prompt using pre-trained models. This endpoint is perfect for creating video clips from your prompt for various use cases such as social media, marketing, and more. Here is an example of how to generate videos using the sunra API: ```javascript Javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("kling/kling-v2-master/text-to-video", { input: { prompt: 'A polar bear playing violin in the snow.', guidance_scale: 0.5, aspect_ratio: '16:9', duration: 5 }, logs: true, onQueueUpdate: (update) => { console.log(update) }, }); console.log(result.data); console.log(result.requestId); ``` ```python Python theme={null} import sunra_client result = sunra_client.subscribe( "kling/kling-v2-master/text-to-video", arguments={ "prompt": "A polar bear playing violin in the snow.", "guidance_scale": 0.5, "aspect_ratio": "16:9", "duration": 5 }, with_logs=True, on_enqueue=print, on_queue_update=print, ) print(result) ``` ```java Java theme={null} import ai.sunra.client.*; import ai.sunra.client.queue.*; import java.util.Map; import com.google.gson.JsonObject; import java.util.function.Consumer; ClientConfig config = ClientConfig.builder() .withCredentials(CredentialsResolver.fromEnv()) .build(); SunraClient client = SunraClient.withConfig(config); Map input = Map.of( "prompt", "A polar bear playing violin in the snow.", "guidance_scale", 0.5, "aspect_ratio", "16:9", "duration", 5 ); Consumer statusUpdateHandler = update -> { String status = update.getStatus().toString(); String message = String.format("\nStatus Update: %s, Request ID: %s", status, update.getRequestId()); System.out.println(message); }; SubscribeOptions options = SubscribeOptions.builder() .input(input) .resultType(JsonObject.class) .onQueueUpdate(statusUpdateHandler) .logs(true) .build(); Output response = client.subscribe("kling/kling-v2-master/text-to-video", options); System.out.println("Completed!"); System.out.println(response.getData()); ``` ```bash Curl theme={null} curl --request POST \ --url https://api.sunra.ai/v1/queue/kling/kling-v2-master/text-to-video \ --header "Authorization: Key $SUNRA_KEY" \ --header "Content-Type: application/json" \ --data '{"prompt":"A polar bear playing violin in the snow.","negative_prompt":"","guidance_scale":0.5,"aspect_ratio":"16:9","duration":5}' ``` ```javascript theme={null} import { sunra } from "@sunra/client"; const result = await sunra.subscribe("kling/kling-v2-master/image-to-video", { input: { prompt: 'A cute hamster lies leisurely on a lifebuoy, wearing fashionable sunglasses, and drifts with the gentle waves on the shimmering sea surface. The hamster reclines comfortably, enjoying a peaceful and pleasant time. Cartoon style, the camera follows the subject moving, with a heartwarming and high picture quality.', start_image: 'https://assets.sunra.ai/uploads/1748811753168-05ceab0d.png' }, logs: true, onQueueUpdate: (update) => { console.log(update) }, }); console.log(result.data); console.log(result.requestId); ``` ### How to select the model to use sunra offers a variety of video generation models. You can select the model that best fits your needs based on the style and quality of the videos you want to generate. Here are some of the available models: * [kling/kling-v2-master/text-to-video](https://sunra.ai/models/kling/kling-v2-master/text-to-video): Kling V2 Master is designed to improve prompt adherence, motion dynamics, and visual aesthetics. * [wan/wan2.1-vace-14b/text-to-video](https://sunra.ai/models/wan/wan2.1-vace-14b/text-to-video): An all-in-one 14B model for video creation and editing, supporting tasks like text-to-video, image-to-video, and video editing with multimodal inputs and advanced control features. * [vidu/viduq1/text-to-video](https://sunra.ai/models/vidu/viduq1/text-to-video): Vidu Q1 — Extreme quality with crisp visuals, cinematic transitions, precise sound, and expressive animations. * [minimax/t2v-01-director/text-to-video](https://sunra.ai/models/minimax/t2v-01-director/text-to-video): The “Camera Control” model affords users cinematic language with the fluency of a professional director and to exercise meticulous creative control. To select a model, simply specify the model ID in the subscribe method as shown in the example above. You can find more models and their descriptions in the [Text to Video Models](https://sunra.ai/models?modalities%5B0%5D=modality-text-video) page. # Authentication Source: https://docs.sunra.ai/platform/authentication All requests to the Sunra API are authenticated with an API key. You can create and manage your keys in your [dashboard](https://sunra.ai/dashboard/api-tokens). ## Setting your API key Set your API key as an environment variable: ```bash theme={null} export SUNRA_KEY="your-api-key-here" ``` The official [client libraries](/client-libraries) read `SUNRA_KEY` from the environment automatically, so no further configuration is needed. Each client also accepts credentials explicitly if you prefer to pass the key in code. ## Raw HTTP requests If you call the API directly over HTTP, pass the key in the `Authorization` header. The two product APIs use different schemes: | API | Base URL | Header | | - | - | - | | Multimodal (predictions) | `api.sunra.ai` | `Authorization: Key $SUNRA_KEY` | | LLM (completions) | `api-llm.sunra.ai` | `Authorization: Bearer $SUNRA_KEY` | The same API key works for both APIs. ## Keeping your key secure Your API key grants access to your account balance — treat it like a password. Never embed it in client-side code (browsers or mobile apps); route requests through your own backend instead. See [Server-side integration](/multimodal/server-side-integration) for the recommended setup. # Budget keys Source: https://docs.sunra.ai/platform/budget-keys A budget key is a short-lived child of an ordinary Sunra API key, with a USD spending cap and its own call receipts. Use one per agent turn or run to give your agent limited LLM access and account for each end user's usage separately. The cap controls admission using estimated cost: actual settled spend can exceed it, so keep a margin in your own authorization hold. Sunra continues to bill the parent key's organization; a budget key is a cap plus attribution, not a wallet. ## Lifecycle at a glance 1. **Mint** with an ordinary key. Save the child `id`, your business `ref`, and the secret returned once. 2. **Use** the child secret for one or more LLM calls before expiry, with an explicit output limit and a call tag. 3. **Inspect** the key's live spend and call receipts with an ordinary key. 4. **Close** when the turn ends. Poll until `status: closed`, then settle your user's authorization hold using the final `spent_usd`. All management requests use `https://api.sunra.ai` and `Authorization: Key \$SUNRA_KEY`, where `SUNRA_KEY` is an active ordinary key. A budget key cannot call management endpoints. See [Authentication](/platform/authentication). ## Media endpoints When media access is enabled, **pool child keys can call all multimodal queue endpoints and initiate uploads**, subject to their model access restrictions. Use `https://api.sunra.ai` with `Authorization: Bearer `: | Method | Endpoint | | - | - | | POST | `/v1/storage/upload/initiate` | | POST | `/v1/queue/{owner}/{model}/{endpoint...}` | | GET | `/v1/queue/requests/{id}`, `/v1/queue/requests/{id}/status`, `/v1/queue/requests/{id}/status/stream` | | PUT | `/v1/queue/requests/{id}/cancel` | Standalone budget keys cannot use these routes (`403 budget_key_route_forbidden`). Upload initiation is unbilled and returns `201`; use its signed upload URL to transfer your file. Each media submission reserves the same amount used for organization credit: **the exact price for `pre_defined` endpoints**, or **the price card's `freeze_amount` for `post_calculated` endpoints**. For example, seed-asr-2.0 reserves $1.00, seedance-2.5 $18–21 depending on the endpoint, and seedance-2.0 $8. Settlement records actual charges, which can exceed the estimate. This is estimated admission, not a hard spending ceiling: if total committed and reserved costs exceed pool authorization, the pool pauses for `liability`; new submissions receive `402 budget_pool_paused`. Add authorization with `credit`, then `resume`. An optional child `cap_usd` also limits estimated admission: a child with a $1 cap can reserve $1 and settle $2 while a sufficiently funded pool remains active. The child's `spent_usd` records \$2 and its next submission is denied. The `estimate_usd` in a `402` admission response gives the amount required for that submission. N concurrent submissions at the same price need N times that amount in available pool authorization before settlement releases reservations; mixed endpoints need the sum of their admission amounts. A $0.10 pool cannot admit a $1.00 audio reservation, and N concurrent $21 video submissions need N × $21 available. Lower actual costs release the difference. Failed calls with a captured charge retain that charge even when the invoice is `VOID`. `x-sunra-call-tag` is validated only when submitting a prediction. A key restricted by `metadata.allowed_tags` must send an allowed tag there; upload, status, result, stream, and cancel do not require a tag. A child can read or cancel **only its own requests**; another child's or a missing request returns `404`, including the stream endpoint. Closing or revoking a child does not cancel in-flight work. Pool child close returns **HTTP 200** with `status: "finalizing"` until invoices settle, then `closed`; final totals include late media charges. `/calls` lists LLM rows first, followed by media rows, each ordered by its database ID. The cursor is a call ID (`chatcmpl_` or `pd_`). Open listings are not snapshots: after a cursor enters the media segment, newly inserted LLM rows are absent from that traversal. For the complete ledger, start a fresh listing after close and require `complete: true`; `calls_count` counts both collections. Media receipts include `endpoint`, non-token `usage: { usage_key, usage, unit_amount }`, the submission tag, and actual `cost_usd`; they have no `wire_id`. Missing media usage fields are explicitly `null`: canceled or charged-VOID calls without usage return `usage: { usage_key: null, usage: null, unit_amount: null }`, including after close. Media receipts never contain token counters. ## Mint a budget key Send `POST /v1/budget-keys` with a JSON body: | Field | Required | Rules | | - | - | - | | `cap_usd` | Yes | Positive decimal USD **string** matching `^\d+(?:\.\d{1,8})?\$`. Range: `0.00000001`–`90071992.54740991`, inclusive. JSON numbers, zero, negative values, exponent notation, and more than 8 decimal places are rejected. Returned with exactly 8 decimal places. | | `expires_at` | Yes | ISO datetime string with `Z` or a timezone offset. Must be in the future and at most 24 hours away when validated. Leave time for request processing. | | `management` | No | `"org"` (default) or `"parent_only"`. Immutable after mint; legacy records without the field mean `org`. Minting `parent_only` is enabled per rollout. | | `ref` | No | String of at most 256 characters for your turn or authorization reference. Use a nonempty value if you want to recover the key by `ref`. It is not an idempotency key. | | `metadata` | No | JSON object whose serialized UTF-8 JSON is at most 8,192 bytes. | | `metadata.allowed_tags` | No | Array of at most 32 strings, each at most 128 printable ASCII characters. If supplied, every call must send a tag in this set. An empty array refuses all calls. | | `models` | No | Array of 1–64 exact, lowercase public `owner/model` IDs within the parent key's allowlist; no wildcards. Each part is 1–64 characters, starts and ends with a lowercase letter or digit, and may contain `.`, `_`, or `-` internally. | Omit optional fields instead of sending `null`. Unknown top-level fields are rejected. If `models` is omitted, the child inherits the parent's model restrictions. If supplied, calls must satisfy both that list and the parent's current allowlist. This example uses `jq` to set expiry one hour from now and mint a \$0.05 key: ```bash theme={null} EXPIRES_AT=$(jq -nr 'now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ")') curl --fail-with-body -sS https://api.sunra.ai/v1/budget-keys \ -H "Authorization: Key $SUNRA_KEY" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg expires "$EXPIRES_AT" '{ cap_usd: "0.05", expires_at: $expires, ref: "turn-123", metadata: {allowed_tags: ["summary"]}, models: ["google/gemini-2.5-flash"] }')" ``` A `201` response is the resource itself, with no `data` wrapper. Example values: ```json theme={null} { "object": "budget_key", "id": "child_example", "parent_api_key_id": "parent_example", "management": "org", "status": "open", "cap_usd": "0.05000000", "expires_at": "2026-09-11T13:00:00.000Z", "ref": "turn-123", "metadata": {"allowed_tags": ["summary"]}, "models": ["google/gemini-2.5-flash"], "spent_usd": "0.00000000", "calls": 0, "reserved_usd": "0.00000000", "in_flight": 0, "closed_at": null, "secret_key": "replace-with-the-returned-child-secret" } ``` **Save `secret_key` immediately: mint returns it only once.** GET, list, receipts, and close never return it. **Mint is not idempotent**: repeating a request, even with the same `ref`, creates a different child. If the response is lost, use the recovery endpoint below to find and close the orphaned key before deciding whether to mint another. ## Call the LLM API with the child key Use `https://api-llm.sunra.ai` with `Authorization: Bearer `. These are the supported LLM endpoints: | Endpoint | Supported calls | | - | - | | `POST /v1/chat/completions` | Streaming and non-streaming [Chat Completions](/llm/chat). | | `POST /v1/messages` | Streaming and non-streaming [Messages](/llm/messages). | | `POST /v1/responses` | Streaming and non-streaming [Responses](/llm/responses). | | `POST /v1/embeddings` | Non-streaming [text embeddings](/llm/embeddings) only. | Apart from the pool-only media routes above, other API operations, including budget-key management and native/media embeddings, refuse the child with `403 budget_key_route_forbidden`. An expired or revoked credential can fail authentication first; an unrecognized URL can still return `404`. For Chat Completions, **send `max_tokens` or `max_completion_tokens` on every child request**, using the field supported by your model. Missing both returns `400 max_tokens_required`. The value must be a positive safe integer; if both fields are supplied, they must be equal. For Messages use `max_tokens`; for Responses use `max_output_tokens`. Those two APIs can fill a missing limit from the model's configured output limit; sending an explicit limit makes the intended allocation clear. For admission, each image, audio, video, or document part reserves an allowance of **8,192 tokens**. If the model prices that media kind separately, the allowance uses its dedicated input rate; otherwise it is folded into `prompt_tokens` and priced at the prompt/input rate. This is an estimation convention, not a provider-enforced bound. Settlement uses actual usage. Text is estimated conservatively from the serialized request (including `tools` and `system`), so text-only calls almost always settle below their reservation. Settled cost can exceed the reservation in three known cases, and you should size your hold margin around whichever apply: prompt-cache writes on Anthropic-format requests bill the cached prefix at the cache-write rate (1.25× the input rate, 2× for a 1-hour `ttl`) and the reservation does not include that premium (Claude models with tool search may write a cache prefix without an explicit `cache_control`); a media part that exceeds the 8,192-token allowance, typical for long audio, video, or multi-page documents; and xAI Grok models, whose reasoning tokens are not bounded by `max_tokens` — a call to those has no reliable upper bound, so exclude them with `models` if the cap must hold. `n` may be omitted, but when present it must be the number `1`. `best_of` is rejected even when it is `1`. A child request may carry only the portable generation parameters listed below, plus the output ceiling for the endpoint you are calling. Anything else — provider extension bags and alternative ceiling spellings such as `extra_body`, `generation_config`, `generationConfig`, `max_new_tokens` — returns `400 invalid_input`, because they can raise the real output limit after translation and invalidate the reservation. Accepted top-level fields: `model`, `provider`, `stream`, `stream_options`, `messages`, `input`, `system`, `instructions`, `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `text`, `temperature`, `top_p`, `top_k`, `stop`, `stop_sequences`, `seed`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `logprobs`, `top_logprobs`, `user`, `metadata`, `store`, `previous_response_id`, `include`, `truncation`, `reasoning`, `reasoning_effort`, `thinking`, `service_tier`, `safety_identifier`, `prompt_cache_key`, `prompt_cache_retention`, `verbosity`, `n`, `encoding_format`, `dimensions`. Requests made with an ordinary key are not restricted this way. ### Reasoning budgets stay under the output ceiling `thinking` and `reasoning`, when present, must be objects. `thinking.type` must be exactly `"enabled"` or `"disabled"`; adaptive, unknown, missing, and non-string types are rejected for every child key. Enabled thinking requires `thinking.budget_tokens`. Every supplied `thinking.budget_tokens` or `reasoning.max_tokens` must be a positive safe integer **strictly below** the admitted output ceiling; a budget equal to or above the ceiling, or a malformed one (null, string, fraction, zero, negative, unsafe integer), returns `400 invalid_input` before any reservation, credit freeze, call record, or upstream request. Malformed values are never treated as omitted. Accepted budgets are forwarded unchanged. For Claude models (including aliases) and Messages requests, the comparison uses at least the provider's 1,024-token minimum reasoning allocation, so a 512-token thinking budget is treated as 1,024. For example, `max_tokens: 64` with `thinking: {"type":"enabled","budget_tokens":1024}` returns `400 invalid_input` (`thinking.budget_tokens must be below the output ceiling`); to use that budget, send an output ceiling of at least 1,025 and fund the child accordingly. Effort-style controls (`reasoning_effort`, `reasoning.effort`, `reasoning: {"enabled": true}`) remain supported and stay inside the ceiling; `thinking: {"type":"disabled"}` remains supported, including the Qwen translation below. Ordinary keys and sessions are unchanged. Label a call with `x-sunra-call-tag`, at most 128 printable ASCII characters. It appears as `tag` in the receipt. If the key has `metadata.allowed_tags`, a missing tag or a tag outside that set returns `403 budget_call_tag_forbidden`. Otherwise, the header is optional. Ordinary keys ignore it entirely. Attach your own per-call label with `x-sunra-call-meta`, for example `x-sunra-call-meta: turn=t_123`. Sunra does not interpret it: it plays no part in authentication, pricing, routing or `metadata.allowed_tags`, is not forwarded to the model provider, and does not affect prompt caching. It is accepted on Chat Completions, Messages, Responses and Embeddings calls made with a budget key and appears unchanged as `meta` in that call's receipt. The value must be at most 256 printable ASCII characters (0x20–0x7E); a longer value or any other character returns `400 invalid_call_meta` before any reservation, call record or upstream request. Standard HTTP parsing applies: surrounding spaces are removed, repeated headers are joined with `, `, and an empty value is treated as absent. Ordinary keys ignore the header. Set `CHILD` to the returned secret. `-i` includes response headers: ```bash theme={null} curl --fail-with-body -sS -i https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer $CHILD" \ -H 'Content-Type: application/json' \ -H 'x-sunra-call-tag: summary' \ -d '{ "model": "google/gemini-2.5-flash", "messages": [{"role": "user", "content": "Say hello in one sentence."}], "max_tokens": 128, "n": 1 }' # Example response header (alongside the JSON response body): # x-sunra-prediction-id: chatcmpl_example ``` Save `x-sunra-prediction-id` to join this call to its receipt. The receipt's `id` is byte-identical to that header; do not substitute a provider response ID. ### Turn thinking off for Qwen Chat For child-key Chat Completions on the mapped Qwen → DashScope routes, use `thinking: {"type":"disabled"}` or `reasoning_effort: "none"`, or both. Sunra consumes these controls and sends the native off switch, preserving your output ceiling and `n`. The child must allow the chosen model and tag. ```bash theme={null} curl --fail-with-body -sS https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer $CHILD_KEY" \ -H 'Content-Type: application/json' \ -H 'x-sunra-call-tag: summary' \ -d '{ "model": "qwen/qwen3.7-flash", "messages": [{"role": "user", "content": "Summarize this in one sentence."}], "max_tokens": 128, "n": 1, "thinking": {"type": "disabled"} }' ``` The supported public IDs are `qwen/qwen3.7-flash`, `qwen/qwen3.7-plus`, `qwen/qwen3.7-max`, and `qwen/qwen3.8-max`, each resolving through LiteLLM to the same-named DashScope group (without the `qwen/` prefix). Support depends on the resolved route; choosing a model name or a `provider` preference alone does not establish support. These cases return `400 invalid_input` before a call record, credit freeze, or budget reservation: * An off intent combined with `reasoning_effort` other than `"none"`, `thinking` other than exactly `{"type":"disabled"}`, or any `reasoning` field. * Extra keys in the disabled `thinking` object, including `budget_tokens`. * An off intent on an unmapped Qwen route, or on Qwen Messages or Responses. * Native `enable_thinking` (including literal `false`) or client `extra_body`: both remain rejected for child keys. Ordinary-key behavior is unchanged; ordinary requests do not receive this child-only translation. Non-Qwen routes and requests without off intent retain their existing behavior. This is not a universal guarantee across providers. **HTTP 200 alone does not prove thinking was off.** Verification needs observed usage, including an explicit `usage.reasoning_tokens: 0`; an absent field means unknown. ## Track spend ### Read the key With `ID` set to the minted child `id`: ```bash theme={null} curl --fail-with-body -sS "https://api.sunra.ai/v1/budget-keys/$ID" \ -H "Authorization: Key $SUNRA_KEY" ``` The response has the same resource fields as mint, without `secret_key`: | Field | Meaning | | - | - | | `spent_usd` | Live spend while open or finalizing; the authoritative, immutable final spend once closed. USD string with 8 decimals, or `null` when the live figure is unavailable. | | `reserved_usd` | Estimated cost held for calls in flight; `null` if unknown. Closed keys return `0.00000000`. | | `in_flight` | Number of calls with an outstanding reservation; `null` if unknown. Closed keys return `0`. | | `calls` | Call-attempt count, including unpaid attempts. Provisional until closed and potentially `null` when unavailable; use the closed count for reconciliation. | | `status` | `open`, `finalizing`, or `closed`. | | `closed_at` | `null` until closed, then the final snapshot timestamp. | Admission reserves the **estimated sell cost** before a call. Real settled cost can exceed the cap by the estimation error of calls in flight. Keep a margin in your own authorization hold; do not release it based on open-key spend or on an LLM response finishing. An unknown live value is not zero. ### Read per-call receipts ```bash theme={null} curl --fail-with-body -sS --get \ "https://api.sunra.ai/v1/budget-keys/$ID/calls" \ -H "Authorization: Key $SUNRA_KEY" \ --data-urlencode 'limit=100' ``` `limit` defaults to 100 and accepts 1–500. While `has_more` is true, request the next page with `after` set to `next_cursor`, which is the last row's `id`. Pass it unchanged and URL-encode it. An invalid cursor or one belonging to another child returns `400 invalid_input`. An illustrative page after closing a key with one paid call: ```json theme={null} { "object": "list", "data": [ { "id": "chatcmpl_example", "model": "google/gemini-2.5-flash", "status": "PAID", "cost_usd": "0.00012000", "created_at": "2026-09-11T12:00:00.000Z", "completed_at": "2026-09-11T12:00:01.000Z", "usage": { "input_tokens": 10, "output_tokens": 20, "cache_read_tokens": 0, "cache_write_tokens": 0 }, "usage_exclusive": true, "cost_breakdown": { "input_usd": "0.00000300", "cache_read_usd": "0.00000000", "cache_write_usd": "0.00000000", "output_usd": "0.00011700" }, "tag": "summary", "meta": "turn=t_123" } ], "has_more": false, "next_cursor": null, "receipt_version": 2, "calls_count": 1, "complete": true } ``` Rows include `PAID`, `VOID`, `PENDING`, and `UNCOLLECTIBLE` attempts. Only `PAID` rows have a settled cost; every other status has `cost_usd: "0.00000000"`. A refusal before a call record is created is not counted. `completed_at`, `tag` and `meta` can be `null`; `wire_id` is optional. `meta` is `null` when the call carried no `x-sunra-call-meta`, for media predictions, and for calls made before this field existed. `usage` always contains four integer counts: `input_tokens`, `output_tokens`, `cache_read_tokens`, and `cache_write_tokens`, with `0` when not applicable. When `invoice.price_breakdown` exists, these counts come from its billing buckets: `input_tokens` is uncached input only, and the three input buckets are mutually exclusive, so **total prompt tokens = input\_tokens + cache\_read\_tokens + cache\_write\_tokens**. `cache_write_tokens` combines the 5-minute and 1-hour cache-write tiers; `output_tokens` is the billed output count. Each row's `usage_exclusive` tells you whether the input buckets are mutually exclusive: it is `true` when usage comes from `price_breakdown` or a valid persisted route dialect stamp lets the receipt separate the raw counts. Without `price_breakdown`, the receipt reads flat or nested raw cache counts on a best-effort basis and subtracts only the buckets the dialect marks as included in input, clamping the result at `0`. If the dialect stamp is missing or malformed, the raw input count is preserved and `usage_exclusive` is `false`; do not assume the sum above holds. Live rows use live data; closed rows use only the sealed invoice, usage, and dialect stamp. Historical sealed invoices can be displayed as v2 without a migration. `PAID` rows with `price_breakdown` also include `cost_breakdown`, with exactly four USD strings at 8 decimal places: `input_usd`, `cache_read_usd`, `cache_write_usd`, and `output_usd`. Using decimal arithmetic, they **sum exactly to `cost_usd`**. Each bucket is calculated as exact usage × unit price, combining 5-minute and 1-hour write costs before rounding to 8 decimal places with ROUND\_HALF\_EVEN. After rounding, `cost_usd − sum(rounded buckets)` is added to the bucket with the largest exact cost; ties use input, cache read, cache write, then output order. The same rule reconciles to the amount actually paid when a refund makes `amount_paid` differ from the invoice total. `cost_breakdown` is absent for non-`PAID` rows and for legacy `PAID` rows without `price_breakdown`. `calls_count` counts all attempts in the receipt across all pages, not just the current page. **`complete` is true only for a closed key**; open and finalizing receipts can change. For final reconciliation, close the key, fetch every receipt page from the beginning, and sum `cost_usd` for `status: "PAID"` using decimal arithmetic. That sum equals the closed `spent_usd`, and the total number of rows equals the closed `calls` and receipt `calls_count`. `has_more: false` alone does not mean the key is closed. `usage.reasoning_tokens` is an optional non-negative integer observed from upstream usage. It is always spelled this one way in the receipt, whichever name the gateway response used: `completion_tokens_details.reasoning_tokens` on Chat Completions, `output_tokens_details.reasoning_tokens` on Responses, and `output_tokens_details.thinking_tokens` on Anthropic-shaped `/v1/messages` responses (Claude). An explicit `0` means upstream reported zero reasoning tokens; absence means upstream reported no count at all, which is unknown, not zero. Positive counts remain output-token detail and are not added again to output tokens or invoices. Live rows use live usage; closed rows use sealed usage only, even if live usage changes later. Historical sealed counts can be exposed when already stored. `receipt_version` is `2`. ### Watch keys in the dashboard The **Budget** tab on [platform.sunra.ai/dashboard/budget](https://platform.sunra.ai/dashboard/budget) lists every budget key in your organization under **Keys**, whatever key minted it and regardless of `management`. Filter by search (ref or name), state, and parent key; open a row to see its per-call receipts. The tab is read-only: minting and closing stay with the parent key on the API. Open keys show the live values described above when available; closed keys show the immutable final spend and call count. Budget keys never appear on the **API tokens** page. ## Close the key ```bash theme={null} curl --fail-with-body -sS -i -X POST \ "https://api.sunra.ai/v1/budget-keys/$ID/close" \ -H "Authorization: Key $SUNRA_KEY" ``` Close takes no body and stops new calls. Calls with the closed child fail with `401 budget_key_revoked`. Already admitted calls are not canceled by close and can still settle. | Response | What to do | | - | - | | `200` with `status: "closed"` | Use `spent_usd` as the final amount. The snapshot never changes afterwards. | | `202` with `status: "finalizing"` | Calls are still in flight or finalization is unfinished. Keep the authorization hold and poll close again until `200 closed`. | | `503 budget_store_unavailable` | Closing has not been confirmed successfully. Keep the hold and retry with backoff. | Closing is idempotent. Repeating it returns the same final snapshot once closed, including a zero-spend snapshot for a key with no calls. You can also poll GET by ID, but GET returns HTTP `200` even while `status` is `finalizing`: always check the body. For standalone keys, GET by ID and close reconcile outstanding holds against persisted settlements, so normal finalization converges once every call’s settlement is recorded. While a standalone key is open, a reservation that outlives its 30-minute admission window is not dropped and never closes the key: it keeps counting toward `reserved_usd` and `in_flight`, so the cap still holds, until GET by ID or a background check (about once a minute) finds the call's recorded settlement and converts it to spend or releases it. A reservation with no recorded call stays reserved until the key is closed or expires. After close or expiry, a reservation whose settlement never arrives is dropped 30 minutes after admission. If live state is lost, finalization waits until `expires_at` plus 30 minutes; unknown live figures remain `null`, not zero. Sealing is lazy: continue polling GET or close after these conditions are met. Storage failures or receipt batching can require further retries. ## End-to-end example Run this in Bash with `curl` (supporting `--fail-with-body`) and `jq`. Set `SUNRA_KEY` to your ordinary key as described in [Authentication](/platform/authentication). The example uses `google/gemini-2.5-flash`; your parent key must allow that model. It mints a \$0.05 key for one hour, makes one call, reads its receipt, closes the key, and prints final spend. Receipt values read before close may still be pending. The exit handler also attempts to close the key if the call or receipt read fails. Save the printed `ref` and child ID in your application so interrupted work can be recovered. ```bash theme={null} set -euo pipefail : "${SUNRA_KEY:?Set SUNRA_KEY to an active ordinary Sunra API key}" WORK_DIR=$(mktemp -d) ID='' close_key() { while true; do if ! HTTP_STATUS=$(curl -sS -o "$WORK_DIR/close.json" -w '%{http_code}' \ -X POST "https://api.sunra.ai/v1/budget-keys/$ID/close" \ -H "Authorization: Key $SUNRA_KEY"); then sleep 2 continue fi case "$HTTP_STATUS" in 200) jq -e '.status == "closed"' "$WORK_DIR/close.json" >/dev/null || return 1 jq -r '"Final spend (USD): " + .spent_usd' "$WORK_DIR/close.json" return 0 ;; 202) sleep 2 ;; 503) jq -e '.error.details.retryable == true' "$WORK_DIR/close.json" >/dev/null || return 1 sleep 2 ;; *) cat "$WORK_DIR/close.json" >&2; return 1 ;; esac done } cleanup() { RESULT=$? trap - EXIT if [[ -n "$ID" ]]; then close_key || RESULT=1 fi rm -rf "$WORK_DIR" exit "$RESULT" } trap cleanup EXIT REF="turn-$(jq -nr 'now * 1000000 | floor')" printf 'Recovery ref: %s\n' "$REF" EXPIRES_AT=$(jq -nr 'now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ")') MINT=$(curl --fail-with-body -sS https://api.sunra.ai/v1/budget-keys \ -H "Authorization: Key $SUNRA_KEY" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg expires "$EXPIRES_AT" --arg ref "$REF" '{ cap_usd: "0.05", expires_at: $expires, ref: $ref, metadata: {allowed_tags: ["summary"]}, models: ["google/gemini-2.5-flash"] }')") ID=$(jq -er '.id' <<<"$MINT") printf 'Child ID: %s\n' "$ID" CHILD=$(jq -er '.secret_key' <<<"$MINT") unset MINT curl --fail-with-body -sS -D "$WORK_DIR/chat.headers" \ -o "$WORK_DIR/chat.json" https://api-llm.sunra.ai/v1/chat/completions \ -H "Authorization: Bearer $CHILD" \ -H 'Content-Type: application/json' \ -H 'x-sunra-call-tag: summary' \ -d '{ "model": "google/gemini-2.5-flash", "messages": [{"role": "user", "content": "Say hello in one sentence."}], "max_tokens": 128, "n": 1 }' CALL_ID=$(awk 'tolower($1) == "x-sunra-prediction-id:" {gsub("\r", "", $2); print $2}' "$WORK_DIR/chat.headers") printf 'x-sunra-prediction-id: %s\n' "$CALL_ID" curl --fail-with-body -sS "https://api.sunra.ai/v1/budget-keys/$ID/calls?limit=100" \ -H "Authorization: Key $SUNRA_KEY" \ | jq --arg id "$CALL_ID" '.data[] | select(.id == $id)' close_key ID='' unset CHILD ``` Do not automatically retry mint after an ambiguous failure. For your application's final per-call accounting, re-read all receipt pages after `closed`, as described above. Repeating an LLM request is not deduplicated and can incur another charge. ## Recovering a lost ticket Use your saved `ref` to find child IDs when a mint response or your local ticket record was lost: ```bash theme={null} curl --fail-with-body -sS --get https://api.sunra.ai/v1/budget-keys \ -H "Authorization: Key $SUNRA_KEY" \ --data-urlencode 'ref=turn-123' \ --data-urlencode 'limit=100' ``` The response is `{ "data": [...], "has_more": false, "next_cursor": null }`, with budget-key resources in `data` and no secrets. `ref`, `parent_api_key_id`, or `closed_by_api_key_id` is required: supply at least one selector. When supplied, `ref` must be nonempty and at most 256 characters; `parent_api_key_id` is an exact string of 1–128 characters, without trimming or case conversion. Supplied selectors mean AND. An unknown or foreign-org parent ID returns an empty list and grants no authority. `limit` defaults to 100 and accepts 1–100. If `has_more` is true, pass the opaque `next_cursor` as URL-encoded `after` to get the next page. Multiple keys can share the same `ref`; inspect all matches and close orphaned keys. List is for recovery. Use GET by ID or close to obtain the final accounting state; do not settle your authorization hold from an open list result. The secret is never recoverable. List visible children by issuing parent without a `ref`: ```bash theme={null} curl --fail-with-body -sS --get https://api.sunra.ai/v1/budget-keys \ -H "Authorization: Key $SUNRA_KEY" \ --data-urlencode 'parent_api_key_id=parent_example' ``` By default, lists order by creation time descending, then internal ID descending, and apply authorization before pagination. `has_more` reflects visible rows only. Valid legacy `ref` cursors continue to work. The cursor anchor must match the current organization, selectors, and visibility; a mismatch returns `400 invalid_input` and requires restarting the scan. Cursors are neither snapshots nor credentials. ### Management visibility and recovery Every budget resource response (mint, get, each list item, and close) includes effective `management`. The mode governs resource reads, call receipts, close, and list membership: | Caller | `org` | `parent_only` | | - | - | - | | Active ordinary issuing parent | Allowed | Allowed | | Active same-org ordinary sibling; parent remains active and ordinary | Allowed | get/calls/close return `403 budget_key_management_forbidden`; list omits the child | | Active same-org ordinary sibling; parent revoked, inactive, suspended, missing, or no longer ordinary | Allowed | Recovery allowed for get/list/calls/close | | Ordinary key in another org | Direct access 404; list empty | Direct access 404; list empty | | Child key or session | Management forbidden | Management forbidden | Recovery uses another active ordinary key in the original organization; no new admin key is required. A failed parent lookup returns retryable `503 budget_store_unavailable`, never recovery access. Reactivating the parent restricts subsequent requests again. Revoking the parent stops new child calls but does not cancel admitted work or seal receipts automatically. This narrows budget-management authorization; it is **not tenancy**. Same-org ordinary keys can still see child calls and costs through `/v1/completions`. A separate reconciler key cannot read `parent_only` receipts while the parent is active: use the issuing parent's credential or choose `org` mode. Use separate organizations when you need tenant isolation. Minting `parent_only` is enabled per rollout. Before enablement it returns `400 budget_key_parent_only_disabled`; never retry by dropping `management`. Filters and effective mode output are unconditional. Operators must finish upgrading all auth/apiv1 management instances and verify indexes before setting `BUDGET_KEYS_PARENT_ONLY_ENABLED=true` in auth. Once protected records exist, rollback must retain their authorization checks. ## What did this key do? Use four source lists independently; there is no composite `/activity` endpoint. Authenticate management reads with an active ordinary key in the same organization. | Source | Request | Time / scope | | - | - | - | | Issued children | `GET /v1/budget-keys?parent_api_key_id=KEY_ID` | Child `created_at`; existing management visibility applies. | | Own completions | `GET /v1/completions?api_key_id=KEY_ID` | Already supported; exact calling-key attribution, not the key's children. | | Closes performed | `GET /v1/budget-keys?closed_by_api_key_id=KEY_ID&time_field=close_requested_at` | First accepted explicit close request; existing management visibility applies. | | Revocations of this key | `GET /v1/api-keys/KEY_ID/revocations` | Recorded ordinary-key transitions to revoked, newest first. | Budget lists require at least one of `ref`, `parent_api_key_id`, or `closed_by_api_key_id`; supplied selectors combine with AND. The closer selector has the same exact-string, 1–128-character rules as the parent selector. Unknown or foreign selector IDs produce an empty list. Optional `time_range_start` and `time_range_end` are inclusive ISO datetimes with a timezone (for example `2026-09-15T12:00:00Z`). Either bound may be omitted; start must not exceed end. `time_field` is `created_at` (default), `close_requested_at`, or `closed_at`. Rows missing the selected timestamp are excluded. Ordering is selected time descending, then internal ID descending. Invalid selectors, ranges, time fields, or cursors return `400 invalid_input`. Every budget resource includes `created_at`, `close_requested_at`, and `closed_by_api_key_id`, with unknown values represented by null. `closed_at` is final receipt sealing time. The first explicit `POST /close` accepted before sealing records its authenticated caller and server timestamp atomically with durable admission revocation, even if the request later returns 503. Retries, another closer, and finalization-lock takeover cannot replace that attribution. Lazy GET, calls-triggered finalization, and mint cleanup leave it null unless an explicit close was accepted before sealing. Historical nulls mean unknown; do not infer the parent was the closer. The revocations response is `{ data: [...], has_more: false }`. Each entry contains `id`, `object: "api_key_revocation"`, `api_key_id`, `revoked_at`, `revoked_by`, and nullable `reactivated_at`. It returns at most the latest **50** entries, by construction, with no cursor. The subject may already be revoked; any same-org active ordinary caller can read it. Unknown or foreign subjects return `404 api_key_not_found`; budget-child subjects return `400 invalid_input`; child/session callers are forbidden. Dependency failures return `503 budget_store_unavailable`, never a fabricated empty history. Actors are `{type: "api_key" | "user" | "system" | "unknown", id: string | null}`. API-key/user actors identify the actual acting principal; member-JWT revocations record the member as `user`, backend/internal revocations use `system` with null ID, and unknown actors use `unknown` with null ID. The creator is never substituted. Repeated revokes while already revoked add nothing. Reactivation stamps the last open entry; a later revoke adds another. Existing member-authorized key detail/list responses also expose latest `revoked_at` / `revoked_by` summaries (retained after reactivation) and `revocation_history`. Historical revoked keys are not backfilled: null summaries and an empty history do not prove the key was never revoked. Older entries beyond 50 are no longer retained; there is no history-completeness marker. Keep each source's cursor convention: * Budget lists use opaque `after=next_cursor` (maximum 256 characters), `limit` 1–100, default 100. Creation-time pages retain the existing cursor and validate its anchor against current filters and visibility. Other time fields use versioned cursors bound to the organization, caller, selectors, range, and time field; changing any requires restarting at page one. A mismatch returns 400. Pages are not snapshots. * Completions retain `starting_after` (the last row ID); `ending_before` is rejected. They sort by internal ID descending, with inclusive creation-time range filters and default limit 10 / maximum 10000; do not pass a budget cursor to them. * Child `/calls` receipts use the exact last completion ID as `after`, `limit` 1–500, default 100. * Revocations return the retained array in one response and accept no pagination cursor. Overlap inclusive time windows and deduplicate children/completions/events by their stable IDs. Track outstanding child IDs and poll GET until closed: creation-time scans cannot reveal later sealing, and close-request scans do not reveal when a final receipt becomes ready. A parent scan with `time_field=closed_at` can supplement polling. Re-read unsettled own completions by ID; their original creation time does not change at settlement. Final receipts remain the settlement authority: after the child closes, fetch every `/calls` page from the beginning, require `complete:true`, and reconcile the PAID-only decimal sum and all-attempt count against final `spent_usd` and `calls`. Activity lists are observations, not another ledger. Do not add child receipts to totals that already include those same child completions. ## Errors Read the HTTP status and `error.details.reason`; the following table covers budget-specific handling. Existing model-access, parameter-validation, organization-balance, and upstream errors can retain their usual response shapes. | HTTP | `reason` | What to do | | - | - | - | | 400 | `max_tokens_required` | Supply the appropriate output limit. Chat requires `max_tokens` or `max_completion_tokens`. | | 400 | `invalid_input` | Fix field types, limits, model restrictions, tag format, cursor, or unsupported output parameters before retrying. Reasoning-budget messages: `thinking.budget_tokens must be below the output ceiling`, `reasoning.max_tokens must be below the output ceiling`, `thinking must be an object` / `reasoning must be an object`, `thinking.budget_tokens must be a positive safe integer` / `reasoning.max_tokens must be a positive safe integer`, `thinking.type must be "enabled" or "disabled"`. | | 401 | `budget_key_expired` | Stop using this child; close it for final accounting. Mint a new key only for a new authorization. | | 401 | `budget_key_revoked` | Stop using this child. Close or query it with an active ordinary key in the same organization. | | 402 | `budget_exhausted` | This call's estimate exceeds the remaining budget even without other reservations. Reduce the request's estimated cost or obtain a new authorization; retrying it unchanged will not help. | | 402 | `budget_reserved` | Retryable. Other calls temporarily reserve the needed budget. Wait for them to settle, then retry with backoff. | | 402 | `budget_state_unknown` | Sunra lost this child's live accounting state. A late reservation alone does not cause it. Stop using this child; close it and wait for final accounting. Do not infer available budget from missing figures. Any further work needs a newly authorized key. | | 400 | `budget_key_parent_only_disabled` | This rollout has not enabled protected minting. Do not automatically downgrade to `org`. | | 403 | `budget_key_management_forbidden` | Use the issuing parent, or an active same-org ordinary key for recovery once the parent is unusable. | | 403 | `budget_key_route_forbidden` | Use an ordinary key for management; use the child on supported LLM routes or the pool-only media routes above. | | 403 | `budget_call_tag_forbidden` | Supply `x-sunra-call-tag` with a value from the key's `metadata.allowed_tags`. | | 404 | `budget_key_not_found` | Check the child ID and the organization of the ordinary key used for management. | | 503 | `budget_media_disabled` | Media admission is disabled; do not automatically downgrade to an ordinary key. | | 503 | `budget_pricing_unavailable` | Required prompt/input or completion/output pricing is unavailable. Retry after pricing is restored. | | 503 | `budget_store_unavailable` | Retryable. Retry with backoff and preserve the authorization hold until closed. For an ambiguous mint failure, recover by `ref` before minting again. | Example `402 budget_reserved` body: ```json theme={null} { "error": { "type": "invalid_request_error", "code": "INSUFFICIENT_CREDIT", "message": "budget_reserved", "details": { "code": "INSUFFICIENT_CREDIT", "message": "budget_reserved", "reason": "budget_reserved", "retryable": true, "spent_usd": "0.00000000", "reserved_usd": "0.04000000", "cap_usd": "0.05000000", "estimate_usd": "0.02000000" } } } ``` `budget_exhausted` and `budget_reserved` include all four USD strings from the admission decision. `budget_state_unknown` includes only `cap_usd` among these amounts; missing spend and reservation values must not be treated as zero. ## Limits and guarantees * **Lifetime:** at most 24 hours from mint validation. Expiry stops new calls; you still need to obtain a closed snapshot for final accounting. * **Best-effort cap:** admission reserves estimated sell cost. Actual spend can exceed `cap_usd` by estimation error across calls in flight. Keep a margin in your own authorization hold. * **Live versus final:** open and finalizing figures can change. Only `status: closed` provides the authoritative, immutable snapshot; `complete: true` marks its receipt. The snapshot covers recorded settlement, so settlement that is never recorded or arrives after the snapshot can be absent from the final amount. * **Billing:** the organization that owns the parent key is billed exactly as before, with its existing balance and access restrictions. A budget key does not fund a wallet or transfer your end user's money into Sunra; your application manages end-user authorization and accounting. # Shared budget pools Source: https://docs.sunra.ai/platform/budget-pools Authorize multiple budget keys and external operations against one temporary USD budget. A budget pool lets multiple budget keys share one temporary authorization. Each key remains an independent billing ticket with its own calls and final receipt. Creating a pool or issuing a key does not transfer money or create an organization charge. Normal organization billing and model access checks still apply to every LLM request. This public feature is initially enabled for selected organizations. Contact Sunra to enable it. A disabled pool request returns `503 budget_pools_disabled`; do not retry it as an unpooled request. ## Amounts and responsibility Pool amounts are decimal USD strings with at most eight fractional digits on input and exactly eight on output. JSON numbers, negative inputs, exponent notation, null, and extra precision are invalid. The maximum admission amount is `90071992.54740991` USD. Authorization may be zero; credits, debits, and an optional child cap must be positive. A pool exposes four amounts: | Field | Meaning | | - | - | | `authorized_usd` (A) | Cumulative authorization granted to this pool. | | `committed_usd` (C) | Persisted paid LLM spend plus successful external debits. | | `in_flight_usd` (F) | Reservations for calls whose financial outcome is not yet settled. | | `available_usd` (R) | A minus C minus F. | Authorization is **not a mirror of your remaining wallet balance**. A credit increases A; it never reduces C. For example, authorize `10`, debit `3`, then credit `1`: A is `11`, C is `3`, and R is `8`. Setting A to the wallet's remaining `8` would deduct the same consumption twice. Sunra maintains C and F. You cannot overwrite them. Setting A below C plus F pauses the pool immediately and may make R negative. Previously admitted work still settles at its actual recorded amount. Adding authorization does not resume a paused pool automatically. An LLM call reserves its estimated maximum sell cost before dispatch. One atomic decision checks both the shared pool and the child's optional cap. External debits compete with those same reservations and commit immediately. A decision that leaves exactly zero available is allowed. Rejected debits do not partially consume authorization and never produce LLM rows or child receipts. ## Media endpoints When media access is enabled, **pool child keys can call all multimodal queue endpoints and initiate uploads**, subject to their model access restrictions. Use `https://api.sunra.ai` with `Authorization: Bearer `: | Method | Endpoint | | - | - | | POST | `/v1/storage/upload/initiate` | | POST | `/v1/queue/{owner}/{model}/{endpoint...}` | | GET | `/v1/queue/requests/{id}`, `/v1/queue/requests/{id}/status`, `/v1/queue/requests/{id}/status/stream` | | PUT | `/v1/queue/requests/{id}/cancel` | Standalone budget keys cannot use these routes (`403 budget_key_route_forbidden`). Upload initiation is unbilled and returns `201`; use its signed upload URL to transfer your file. Each media submission reserves the same amount used for organization credit: **the exact price for `pre_defined` endpoints**, or **the price card's `freeze_amount` for `post_calculated` endpoints**. For example, seed-asr-2.0 reserves $1.00, seedance-2.5 $18–21 depending on the endpoint, and seedance-2.0 $8. Settlement records actual charges, which can exceed the estimate. This is estimated admission, not a hard spending ceiling: if total committed and reserved costs exceed pool authorization, the pool pauses for `liability`; new submissions receive `402 budget_pool_paused`. Add authorization with `credit`, then `resume`. An optional child `cap_usd` also limits estimated admission: a child with a $1 cap can reserve $1 and settle $2 while a sufficiently funded pool remains active. The child's `spent_usd` records \$2 and its next submission is denied. The `estimate_usd` in a `402` admission response gives the amount required for that submission. N concurrent submissions at the same price need N times that amount in available pool authorization before settlement releases reservations; mixed endpoints need the sum of their admission amounts. A $0.10 pool cannot admit a $1.00 audio reservation, and N concurrent $21 video submissions need N × $21 available. Lower actual costs release the difference. Failed calls with a captured charge retain that charge even when the invoice is `VOID`. `x-sunra-call-tag` is validated only when submitting a prediction. A key restricted by `metadata.allowed_tags` must send an allowed tag there; upload, status, result, stream, and cancel do not require a tag. A child can read or cancel **only its own requests**; another child's or a missing request returns `404`, including the stream endpoint. Closing or revoking a child does not cancel in-flight work. Pool child close returns **HTTP 200** with `status: "finalizing"` until invoices settle, then `closed`; final totals include late media charges. `/calls` lists LLM rows first, followed by media rows, each ordered by its database ID. The cursor is a call ID (`chatcmpl_` or `pd_`). Open listings are not snapshots: after a cursor enters the media segment, newly inserted LLM rows are absent from that traversal. For the complete ledger, start a fresh listing after close and require `complete: true`; `calls_count` counts both collections. Media receipts include `endpoint`, non-token `usage: { usage_key, usage, unit_amount }`, the submission tag, and actual `cost_usd`; they have no `wire_id`. Missing media usage fields are explicitly `null`: canceled or charged-VOID calls without usage return `usage: { usage_key: null, usage: null, unit_amount: null }`, including after close. Media receipts never contain token counters. ## Authentication and management Manage pools and keys on `https://api.sunra.ai/v1` with an active ordinary API key. Use the issued child secret on `https://api-llm.sunra.ai/v1` for LLM calls. Budget children and user sessions cannot manage pools. `management` is immutable and defaults to `org`: * `org`: active ordinary keys in the same organization can manage the pool. * `parent_only`: the issuing parent manages the pool while it remains active. If that parent is definitively inactive, revoked, suspended, or missing, another active ordinary key in the same organization can recover, read, pause, and close it. A failed parent lookup does not grant recovery access. Only the original active parent can mint new pooled children. Children inherit the pool's parent and management mode. A replacement key should finish the old pool and create a new generation. Foreign-organization pool IDs return `404 budget_pool_not_found`. Management protection is not a separate confidentiality boundary within an organization; existing organization-wide completion access is unchanged. After the original parent becomes inactive, a recovery key cannot debit or resume that pool. It can still confirm historical results, add a repair credit, pause, and close. Confirmed operation replays preserve their original results. ### Watch pools in the dashboard The **Budget** tab on [platform.sunra.ai/dashboard/budget](https://platform.sunra.ai/dashboard/budget) lists your organization's pools under **Pools**: ref, state (with the pause reason when paused), authorized and committed amounts, open children, and the parent key. Filter by search, state, and parent key. It is read-only; pausing, resuming, and closing remain API operations with an ordinary key. Organizations without pool access see the tab with a "not enabled" notice. ## Create and recover a pool ```http theme={null} POST /v1/budget-pools Authorization: Bearer Content-Type: application/json { "ref": "account-opaque-reference", "authorized_usd": "10.00000000", "expected_generation": "0", "management": "parent_only" } ``` The first creation returns `201` and a `budget_pool` resource. The creation identity is organization, `ref`, and `expected_generation`. Repeating the same normalized parameters returns `200` with the same pool ID and its current snapshot. Different parameters return `409 budget_pool_conflict`. For a new creation identity, an `expected_generation` that does not match the current head generation, or a head that is not closed, returns `409 budget_pool_generation_mismatch` with `details.current_generation` and `details.current_state` (when no head exists, `current_generation` is `"0"` and `current_state` is omitted). `ref` must contain 1–256 characters. Use `expected_generation: "0"` for the first pool. After generation `"1"` is closed, use `expected_generation: "1"` to create generation `"2"`. A new generation has a new pool ID. An old create retry always finds its original pool; it cannot open a new generation. Only one current generation can exist per organization and ref. ```http theme={null} GET /v1/budget-pools/{pool_id} GET /v1/budget-pools?ref=account-opaque-reference&state=active&limit=100 ``` The list accepts optional exact `ref` and `state` (`active`, `paused`, or `closed`) filters, `limit` from 1 through 100 (default 100), and an opaque `after` cursor. It returns `{data, has_more, next_cursor}` in newest-creation order, which gives descending generations for a ref. Authorization is applied before pagination. Keep filters and the calling credential unchanged when following a cursor. Lists are not financial snapshots across pages. A pool resource includes `id`, `organization`, `ref`, `generation`, `version`, `state`, the four amounts, `management`, `parent_api_key_id`, `created_at`, `last_admitted_at`, `pause_reason`, `close_requested_at`, `closed_at`, `closed_by_api_key_id`, and `open_children`. Unset lifecycle timestamps and attribution are null. Generation and version are decimal **strings**. Versions increase on actual state changes and can jump after recovery; they are not wallet event watermarks. ## Issue a turn key ```http theme={null} POST /v1/budget-keys Authorization: Bearer Content-Type: application/json { "pool_id": "", "pool_generation": "1", "ref": "turn-opaque-reference", "expires_at": "2026-09-20T12:00:00Z", "models": ["openai/gpt-5.5"] } ``` Choose a future expiry no later than seven days after the pool was created. `ref` is required and identifies one logical ticket within the pool. Existing `models`, `metadata`, and call-tag restrictions still apply. You may add a positive `cap_usd` as a second, child-specific bound; omitting it leaves the key subject to the shared pool alone. The child resource omits `cap_usd` when none was set and includes `pool_id` and `pool_generation`. The first successful mint returns `201` with one secret. The same pool/ref and parameters return `200` with the same child and **no secret**. Different parameters return `409 budget_key_mint_conflict`. Only the secret hash is stored. If the initial response was lost before you saved the secret, recover the child ID, close that ticket, and finish its accounting. Do not reuse its ref to create another ticket or expect to recover the secret. ```http theme={null} GET /v1/budget-keys?pool_id={pool_id}&ref={turn_ref} GET /v1/budget-keys/{child_id} GET /v1/budget-keys/{child_id}/calls POST /v1/budget-keys/{child_id}/close ``` The pool selector may be used without ref. Existing child list pagination and permissions apply. Closing A stops A's new calls while B can continue using the pool. A's accepted calls remain responsible for their costs. A pooled child that expires stops accepting new calls but does not automatically close its financial ticket. Explicitly close it and wait for `status: "closed"` before treating its receipt as final. Pooled child close returns `200` with `finalizing` or `closed`; inspect the resource status. Receipts retain version 1, call identifiers, sealed paid amounts, and full pagination. Fetch all call pages and verify their paid sum against the child's final spend. External operations are excluded from these receipts. Recording that receipt in your wallet must not debit the pool again. Each ticket has one secret. This release does not support extending expiry or replacing the secret on an existing ticket. ## Change authorization or pause admission ```http theme={null} PATCH /v1/budget-pools/{pool_id} If-Match: "" Content-Type: application/json { "authorized_usd": "8.00000000", "generation": "1", "mutation_id": "authorization-event-42" } ``` PATCH sets cumulative A absolutely. It requires the version from a prior snapshot in `If-Match` so a stale wallet update cannot overwrite a concurrent credit. A stale version returns `409 budget_pool_version_conflict`. ```http theme={null} POST /v1/budget-pools/{pool_id}/pause {"generation":"1","mutation_id":"pause-42"} ``` Pause immediately stops new LLM admissions, debits, and mints. It does not require `If-Match`. Reads, settlement, and credits remain available. ```http theme={null} POST /v1/budget-pools/{pool_id}/resume If-Match: "" {"generation":"1","mutation_id":"resume-42"} ``` Resume requires known accounting, sufficient authorization for existing liabilities, no close request, and an age below seven days. It does not restore spent authorization. A successful resume starts a fresh idle window. PATCH and lifecycle mutations use 1–128-character mutation IDs: the same ID and parameters replay the stored decision; changed parameters return `409 budget_mutation_conflict`. Replays do not increment the version. A pool pauses after 24 hours without a successful LLM admission or external debit, or seven days after creation. Reads, failed admissions, credits, and PATCH do not extend its activity window. Expiry never automatically closes the pool. An over-age pool must be finished and replaced with a new generation. ## Debit, credit, and recover an operation ```http theme={null} POST /v1/budget-pools/{pool_id}/operations Content-Type: application/json { "operation_id": "generation-order-42", "type": "debit", "amount_usd": "0.75000000", "generation": "1", "reason": "External generation authorization" } ``` An applied first operation returns `201`; an applied replay returns `200`. Persist your operation ID before making the request. Do not start external work until its debit is confirmed applied. The external work's later success does not debit this pool again. Use `type: "credit"` to grant more authorization after a confirmed top-up or refund. A credit may include `related_operation_id` pointing to an applied debit in the same pool. This link is for audit; Sunra does not implement your order or refund state machine. `reason` is optional, at most 256 characters, and has no authorization effect. Operation IDs are opaque strings of 1–128 characters. ```http theme={null} GET /v1/budget-pools/{pool_id}/operations/{operation_id} GET /v1/budget-pools/{pool_id}/operations?limit=100&after={cursor} ``` Operation resources contain the submitted parameters, `status` (`pending`, `applied`, or `rejected`), timestamps, `result_version`, and the decision's pool snapshot. Applied and rejected results are immutable. A replay returns the original decision snapshot, even if later operations changed the pool. Amounts compare after normalization; type, generation, reason, and related ID also participate in conflict detection. A store timeout with an unresolved decision can return `202 pending`. Query or retry **the same ID**. A `404` only means no durable intent was found; it does not authorize choosing a new ID. A rejected debit remains rejected after later credit; a genuinely new attempt needs a new persisted operation ID. A changed request under an existing ID returns `409 budget_operation_conflict`. Operation lists use ascending creation order, an opaque pool-bound cursor, and the same 1–100 page size. Poll pending IDs separately because a paginated scan is not a stream of status changes. ## Close a pool ```http theme={null} POST /v1/budget-pools/{pool_id}/close Content-Type: application/json {"generation":"1","mutation_id":"close-42","force":false} ``` Close without force rejects with `409 budget_pool_children_open` if any child or mint membership is still open. It has no closing side effect in that case. `force: true` first stops pool admission, then closes children through their existing finalization path, including children whose mint response is still being recovered. A completed close returns `200 closed`. If accepted responsibilities or membership outcomes remain unresolved, it returns `202 paused` with `close_requested_at` set. Retry the same close operation. A close request is irreversible: new mints, resume, PATCH, debits, and credits are blocked, but accepted work can settle. Closure does not refund R because no funds were transferred into the pool. Closed pools never reopen. Their snapshots, operations, child records, and receipts remain available for audit. Old IDs and generations cannot mutate a new generation. Confirmed historical operations still replay after closure. ## Poolable models and ceiling translation For LLM calls, pooled children use an explicit model allowlist: models are admitted based on measured output-ceiling behavior, with a fixed reservation overhead for the documented small overshoots below. Unlisted models return `400 model_not_poolable`, including the six reasoning Grok models `grok-4.20`, `grok-4.3`, `grok-4.5`, `grok-4.6`, `grok-4.7`, and `grok-build-0.1`. Ordinary keys and unpooled children keep their existing behavior. The following eight models require Chat Completions translation: * Ark: `doubao-seed-2.1-pro`, `doubao-seed-2.1-turbo`, `doubao-seed-evolving`, `glm-5.2`. * DashScope: `qwen3.7-flash`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.8-max`. For these models, Sunra sends the admitted ceiling as `max_completion_tokens` and removes `max_tokens` from the upstream copy. This ceiling includes reasoning output. The client body, reasoning intent, and reservation formula are unchanged. Pooled Responses and Messages requests on these eight models return `400 invalid_input` because the translation is not verified for those formats. The 76 native-ceiling model IDs are (84 poolable chat models including the eight translated IDs): * `claude-fable-5` * `claude-fable-5-1` * `claude-opus-4-7` * `claude-opus-4-8` * `claude-opus-5` * `claude-opus-5-5` * `claude-sonnet-5` * `claude-sonnet-5-5` * `claude-haiku-5-5` * `gpt-5.4-pro` * `claude-opus-4-6-aws` * `claude-opus-4-7-aws` * `claude-opus-4-8-aws` * `claude-sonnet-4-6-aws` * `claude-opus-4-6-reverse` * `claude-sonnet-4-6-reverse` * `claude-opus-4-6` * `claude-opus-4-6-openrouter` * `claude-sonnet-4-6` * `claude-sonnet-4-6-openrouter` * `deepseek-flash` * `deepseek-v4-flash` * `deepseek-v4-flash-vision-exp` * `deepseek-v4-pro` * `gemini-2.5-flash` * `gemini-2.5-flash-gcp` * `gemini-2.5-flash-openrouter` * `gemini-2.5-pro` * `gemini-2.5-pro-gcp` * `gemini-2.5-pro-openrouter` * `gemini-3.1-flash-lite-preview` * `gemini-3.1-pro-preview` * `gemini-3.1-pro-preview-gcp` * `gemini-3.1-pro-preview-openrouter` * `gemini-3.5-flash` * `gemini-3.5-flash-gcp` * `gemini-3.5-flash-lite` * `gemini-3.6-flash` * `gemini-3.7-flash` * `gemini-3.8-flash` * `gemini-4-argon` * `gemini-4-argon-gcp` (route group for `gemini-4-argon`, not a separate public ID) * `glm-5` * `glm-5-turbo` * `glm-5.3` * `glm-5.3-flash` * `gpt-4o-mini` * `gpt-5-mini` * `gpt-5-nano` * `gpt-5.3-codex` * `gpt-5.4` * `gpt-5.5` * `gpt-5.6-luna` * `gpt-5.6-sol` * `gpt-5.6-terra` * `gpt-6-astra` * `gpt-6-luna` * `gpt-6-sol` * `gpt-6.1-sol` * `gpt-oss-120b` * `grok-4.20-non-reasoning` * `kimi-k2.6` * `kimi-k2.7-code` * `kimi-k2.7-code-highspeed` * `kimi-k3` * `longcat-2.0` * `minimax-m2-her` * `minimax-m2.1` * `minimax-m2.5` * `muse-spark-1.1` * `muse-spark-1.2` * `muse-wonder` * `o3` * `o4-mini` * `qwen3.5-plus` * `seed-2.0-lite` * `seed-2.0-mini` Claude `-reverse` and `-relay` aliases are admitted because they serve the same Claude models on Anthropic-format upstreams. Legacy `-aws` aliases can degrade to the unpinned default routes. Only upstream routes qualified under the same ceiling rule may execute; a request that resolves to an unqualified route fails closed with `400 model_not_poolable`. Text `/v1/embeddings` requests, including `gemini-embedding-2`, retain prompt-only reservation and need no completion allowlist entry. Native/media embeddings remain forbidden for child keys. `minimax-m2.7` is delisted. These are exact released model names after public-model resolution, not family or prefix permissions. New models and aliases require a billed-output ceiling acceptance check before being added. `grok-4.20-non-reasoning` is a separate measured native entry; it does not authorize any reasoning Grok sibling. For Chat Completions, provide a positive integer `max_tokens` or `max_completion_tokens`. Supplying both with different values is invalid. Native Responses and Messages retain the existing output-limit fallback where available. Pooled children reject any `previous_response_id` with `400 invalid_input`; hidden prior context cannot be included in the admission estimate. The [reasoning-budget validation](/platform/budget-keys#reasoning-budgets-stay-under-the-output-ceiling) for budget keys runs before pool reservation, using the original output ceiling before any reservation overhead. Existing child output-parameter validation, single-completion restriction, model restrictions, and thinking-off rules remain in force. ## Errors Errors use the existing v2 `error` envelope. Inspect `error.details.reason` and `retryable`. HTTP 402 uses coarse code `INSUFFICIENT_CREDIT`, HTTP 503 uses `INTERNAL_ERROR`, and the other budget errors use `HTTP_EXCEPTION`. | HTTP | Reason | Meaning | | - | - | - | | 400 | `invalid_input` | Invalid field, amount, expiry, cursor, unsupported translated format, or hidden response history. Unknown request fields are rejected. | | 400 | `max_tokens_required` | A required output ceiling is missing. | | 400 | `model_not_poolable` | The resolved LLM model is outside the pool allowlist. | | 401 | `budget_key_expired`, `budget_key_revoked` | The child cannot accept new requests. | | 403 | `budget_key_route_forbidden` | A child or session attempted management. | | 403 | `budget_pool_management_forbidden`, `budget_key_management_forbidden` | The caller is not authorized by the target's management policy. | | 404 | `budget_pool_not_found`, `budget_operation_not_found`, `budget_key_not_found` | The scoped resource is absent or belongs to another organization. | | 402 | `budget_exhausted` | The pool or optional child cap cannot cover the request. | | 402 | `budget_pool_paused` | Admission is paused; details include `pause_reason`. | | 409 | `budget_pool_conflict`, `budget_key_mint_conflict` | A create or mint identity was reused with different parameters. | | 409 | `decision_unavailable` | Recovery rejected an unconfirmed decision after Redis state loss and the 60-second grace window. Query or retry the same ID for its stored rejection; a new attempt requires a new operation ID. | | 409 | `budget_operation_conflict`, `budget_mutation_conflict` | An operation or mutation ID was reused with different parameters. | | 409 | `budget_pool_generation_mismatch` | A write targeted the wrong generation, including create with a mismatched `expected_generation` or a head that is not closed. Create errors include `details.current_generation` and, when a head exists, `details.current_state`. | | 409 | `budget_pool_version_conflict` | `If-Match` is stale. | | 409 | `budget_pool_children_open` | Close requires children to be closed or `force: true`. | | 409 | `budget_pool_closing`, `budget_pool_closed` | The pool no longer accepts the requested mutation. | | 409 | `budget_pool_not_resumable` | Liabilities exceed authorization or the pool is too old. | | 503 | `budget_media_disabled` | Media admission is disabled; do not automatically downgrade to an ordinary key. | | 503 | `budget_store_unavailable`, `budget_state_unknown` | Dependencies or recovery cannot establish reliable accounting. Retry with the same identity. | | 503 | `budget_pricing_unavailable` | Required admission pricing is unavailable. | | 503 | `budget_pools_disabled` | New pool admission is disabled for this organization; automatic retry or downgrade is inappropriate. | `budget_exhausted` details include the same atomic decision's `authorized_usd`, `committed_usd`, `in_flight_usd`, and `estimate_usd`, plus `scope: pool|child`. A child-bound refusal also includes `spent_usd`, `reserved_usd`, and `cap_usd`. `blocking: in_flight` means outstanding reservations are the blocker and `retryable` is true. `blocking: committed` means waiting for reservations alone will not make the request fit. Operation refusals include `operation_id`. ## Recovery and limits Pool execution is temporary: idle timeout is 24 hours and maximum age is seven days. It is not a permanent wallet. Your application coordinates wallet updates, generation changes, external work, and which wallet events are already included in a new pool's initial authorization. Sunra does not implement funding buckets, subscriptions, order state, or wallet shortfall compensation. Wallet compensation does not automatically grant new pool authorization. The supported-model contract targets an output ceiling that includes all billed output, including reasoning where declared. Admission still uses the existing conservative prompt-byte estimate and reserves the full output ceiling. Each recognized image, audio, video, or document part contributes 8,192 input tokens: a dedicated media price is used when present, otherwise the allowance is folded into prompt pricing. These input conventions can reject requests that would have cost less. A small positive available amount does not guarantee admission; lower a suitable output ceiling or wait for in-flight work to settle. Actual paid amounts are retained rather than truncated to authorization. A deliberate downward authorization adjustment can leave previously accepted responsibility above the new authorization. Strict actual-cost coverage requires acceptance verification of provider behavior and input/media accounting. The initial acceptance record for `qwen3.5-plus` contains 66 billed output tokens at a requested ceiling of 64. By the September 19 admission decision, pool reservations for this model price the admitted ceiling plus two completion tokens (64 reserves 66). The ceiling sent upstream and the client body remain unchanged. From October 14, 2026, `muse-wonder`, `muse-spark-1.1` and `muse-spark-1.2` reserve the requested ceiling plus 64 completion tokens; the client body and upstream ceiling remain unchanged, and payment uses actual usage. This fixed reserve covers the measured overshoots; it is not a provider guarantee for every request. All models other than these Muse IDs and `qwen3.5-plus` have zero completion overhead. Local tests verify this reservation behavior, not a new live-provider measurement. Redis state loss stops new spending. Recovery reconstructs paid spend, applied external operations, and open completion reservations from durable records, then leaves the pool paused for an explicit resume. Open journal entries retain their financial responsibility even after the request lease expires. Only an expired reservation with no completion row can be reclaimed as an undispatched orphan. If Redis disappears while an external operation or lifecycle decision is still awaiting durable confirmation, its exact decision may be unknowable. During recovery, an operation from an older pool epoch remains pending until it is more than 60 seconds old. If no applied decision has been durably saved, recovery rejects it with `decision_unavailable`; it is never blindly debited or credited again. Query or retry the same ID to retrieve that immutable rejection. A genuinely new attempt requires a new persisted operation ID. A timeout or a finalizing close is not proof of zero cost. Contact Sunra with the pool and operation IDs if recovery remains pending. Client disconnects use the existing cancellation and persisted settlement-evidence path. Receipts describe durable Sunra billing records; they do not invent usage that was never recorded. New provider routes require renewed boundary verification, and the measured model matrix is not a guarantee that future provider implementations will preserve today's behavior. Operators can disable new pool admission independently of reads, settlement, credit repair, and close. All auth and gateway instances must understand pooled credentials before enabling the feature. Rollback must first stop admission and finish existing financial responsibility; it must preserve readers for retained receipts and operations. The deployment gates are `BUDGET_POOLS_ENABLED` (default `false`), `BUDGET_POOLS_ORG_ALLOWLIST` (comma-separated organization IDs; empty grants none), and `BUDGET_POOLS_ADMISSION_ENABLED` (set to `false` to stop new admissions; otherwise enabled subject to the first two gates). Configure the same gates on auth and apiv1. Existing unpooled credentials retain their existing contract when these gates are off. # Errors Source: https://docs.sunra.ai/platform/errors Errors surface in two different places, with two different vocabularies: * **API request errors** — the HTTP response to a synchronous API call (authentication, billing, malformed requests, missing resources). See [API request errors](#api-request-errors). * **Prediction failure codes** — a request was accepted and queued, but the prediction itself ended in `status: "failed"`. The failure is described by the `error` object on the prediction, which you get from the prediction status endpoints, from webhook deliveries, and from the result endpoint. See [Prediction failure codes](#prediction-failure-codes). ## Prediction failure codes A failed prediction carries an `error` object: ```json theme={null} { "status": "failed", "error": { "code": "internal_server_error", "reason": "provider_account_error", "retryable": false, "message": "This model is temporarily unavailable due to an upstream account issue on our side. Retrying will not help; we have been alerted." } } ``` | Field | Type | Description | | - | - | - | | `code` | string | Coarse classification: broadly, whose problem this is. Always present. **Open set** — see [Unknown values](#unknown-values). | | `reason` | string, optional | Fine-grained, machine-readable cause. **Open set.** Absent when the failure has not been classified into a specific cause; absence is not a signal in itself. | | `retryable` | boolean, optional | Whether retrying the *same* input unchanged can succeed. When present, this is authoritative and overrides any per-code default. | | `message` | string | Human-readable explanation, for logs and end users. Wording changes without notice — never parse it or match on it. | | `timestamp` | string, optional | ISO 8601 time at which the **prediction** failed — not the time of the response carrying it. Absent for a failure that recorded no end time. | Failed predictions are not charged; any credits reserved for the request are released. A prediction that has **not** failed carries `error: null`. See [Migration note](#migration-note-error-is-null-when-there-is-no-error) if you integrated before 17 August 2026. ### Where the error reaches you The same `error` object is published on every channel, so you can read it wherever you already look: | Channel | Where the object sits | | - | - | | `GET /v1/queue/requests/{request_id}/status` and `/status/stream` | `error`, alongside `success: false` | | `GET /v1/queue/requests/{request_id}` (the result endpoint) | `error.details` of a `400` — see below | | `GET /v1/predictions/{prediction_id}` | `error` | | Webhook delivery | `error` of the `failed` payload | #### Reading a failure from the result endpoint Asking for the output of a prediction that failed returns `400` with the failure that caused it, under the request-level code `PREDICTION_FAILED`: ```json theme={null} { "error": { "type": "invalid_request_error", "code": "PREDICTION_FAILED", "message": "The prediction may contain sensitive or restricted content and has been suppressed. Please revise your input.", "details": { "code": "unsafe_content", "message": "The prediction may contain sensitive or restricted content and has been suppressed. Please revise your input.", "reason": "moderation_blocked", "retryable": false, "timestamp": "2026-08-14T17:51:47.206Z" } }, "request_id": "req_01JXQ2ZK8W3P5N7V0T6Y4M9DQR", "timestamp": "2026-08-17T09:00:00.000Z", "path": "/v1/queue/requests/pd_a839exsaLNmVZzWvDaxAuapS" } ``` Two codes, at two levels, and they are not the same vocabulary: * `error.code` is `PREDICTION_FAILED`, an [API request error](#api-request-errors) meaning "the prediction you asked for the output of did not produce one"; * `error.details` is the prediction's own `error` object — identical to what `/status` reports for the same request, so the two endpoints can never disagree about why it failed. Branch on `error.details.reason` and `error.details.retryable`. `error.message` repeats the prediction's message, so a client that only ever reads the top-level message still gets something useful. **Two timestamps, two meanings.** The top-level `timestamp` is when this HTTP response was produced; `error.details.timestamp` is when the prediction failed. For a prediction you fetch days later, they are days apart. Read the inner one whenever you mean the failure. The official [client libraries](/client-libraries/javascript) unpack this for you: `result()` raises an error whose `code`, `reason` and `retryable` are the prediction's own, identical to what `subscribe()` raises for the same failure. A prediction that is still queued or running, or one that was cancelled, keeps returning the same generic `400` it always has. ### Deciding whether to retry Read `retryable`. It answers exactly one question — *will submitting this identical request again have a different outcome?* — and it is independent of `code`: the same `code` can be retryable in one failure and not in another. `retryable` is optional. When it is **absent**, fall back to the per-code default in the table below. That is the behaviour Sunra had before this field existed, so falling back is always safe; it is simply less precise. ### `code` | Code | Default when `retryable` is absent | Meaning | | - | - | - | | `invalid_input` | No — fix the input first | The input was rejected, either by schema validation or by the model provider (e.g. text over a length limit, an image that cannot be decoded, an invalid parameter combination), or an input file could not be fetched. The `message` states the specific problem. | | `unsafe_content` | No — revise the content first | The prompt or input media was blocked by the model provider's safety system, or the generated output was suppressed by it. Retrying the same content will be blocked again; change the prompt or media instead. | | `service_provider_error` | Yes — may be transient | The upstream model provider failed to serve the request (provider outage, rate limiting). Retrying later, or with a different model, may succeed. | | `internal_server_error` | Yes | A failure on Sunra's side. Some of these are transient and worth retrying; others (a misconfigured endpoint, a problem with our upstream account) are not, and those carry `retryable: false`. | | `task_timeout` | Yes | The prediction did not finish within the allowed time. Retrying may succeed, especially at lower load. | ### `reason` `reason` names the specific cause behind `code`. Use it for metrics, routing and support diagnostics; use `retryable` for the retry decision. Every reason published today has its own section below, and its anchor is the reason itself — `#input_fetch_failed`, `#moderation_blocked` and so on — so an error report can link straight to what it means. | Reason | `code` | `retryable` | | - | - | - | | [`input_validation_failed`](#input_validation_failed) | `invalid_input` | `false` | | [`provider_rejected_input`](#provider_rejected_input) | `invalid_input` | `false` | | [`input_fetch_failed`](#input_fetch_failed) | `invalid_input` | `false` or `true` | | [`moderation_blocked`](#moderation_blocked) | `unsafe_content` | `false` | | [`provider_rate_limited`](#provider_rate_limited) | `service_provider_error` | `true` | | [`provider_unavailable`](#provider_unavailable) | `service_provider_error` | `true` | | [`provider_timeout`](#provider_timeout) | `task_timeout` | `true` | | [`provider_account_error`](#provider_account_error) | `internal_server_error` | `false` | | [`endpoint_misconfigured`](#endpoint_misconfigured) | `internal_server_error` | `false` | | [`queue_enqueue_failed`](#queue_enqueue_failed) | `internal_server_error` | `true` | | [`reaped_stalled`](#reaped_stalled) | `internal_server_error` | `true` | | [`worker_process_fault`](#worker_process_fault) | `internal_server_error` | `true` | #### `input_validation_failed` `code: invalid_input` · `retryable: false` The request failed Sunra's own schema or parameter validation. The `message` names the offending field. **What to do:** fix the named field. The same request will keep failing. #### `provider_rejected_input` `code: invalid_input` · `retryable: false` The model provider rejected one or more input parameters. Where the provider tells us which, the `message` says so; some providers only report that the input was unacceptable, and we say exactly that rather than guessing. **What to do:** review your parameters against the model's schema — a value out of range, an unsupported combination, an aspect ratio or duration the model does not accept. #### `input_fetch_failed` `code: invalid_input` · `retryable: false` **or** `true` An input file referenced by URL could not be fetched. This is the one reason whose `retryable` genuinely varies, so read the field rather than assuming: * `false` — the host rejected us: a `4xx`, an address we are not permitted to fetch from, a redirect, or a file over the size limit; * `true` — the host was momentarily unreachable: a `5xx`, a timeout, or a DNS failure. The `message` echoes the URL you submitted so you can tell which input failed — with its query string removed, so credentials carried in a presigned URL are never reflected back to you: ```json theme={null} { "code": "invalid_input", "reason": "input_fetch_failed", "retryable": false, "message": "Failed to fetch the input file at https://media.example.com/uploads/reference.png: HTTP 403." } ``` **What to do:** when `retryable` is `false`, check that the URL is publicly reachable and that a presigned link has not expired. When it is `true`, retry. #### `moderation_blocked` `code: unsafe_content` · `retryable: false` Blocked by the model provider's content safety system — either the prompt and input media going in, or the generated output coming back. **What to do:** change the prompt or the media. Resubmitting the same content will be blocked again. #### `provider_rate_limited` `code: service_provider_error` · `retryable: true` The model provider is rate limiting requests. **What to do:** retry with backoff, or route to another model. #### `provider_unavailable` `code: service_provider_error` · `retryable: true` The model provider failed to serve the request. **What to do:** retry later, or route to another model. #### `provider_timeout` `code: task_timeout` · `retryable: true` The prediction did not complete within the allowed time. **What to do:** retry — a lower-load period, or a smaller request (shorter duration, lower resolution), improves the odds. #### `provider_account_error` `code: internal_server_error` · `retryable: false` Our own account with the model provider has a problem — billing, credentials, or a permanent quota. This is our fault, not your input's. **What to do:** nothing on your side; retrying will not help, and we are alerted automatically. Route to another model if you need the capacity now. #### `endpoint_misconfigured` `code: internal_server_error` · `retryable: false` This model endpoint is misconfigured on our side. **What to do:** nothing on your side; retrying will not help, and we are alerted automatically. Contact support with the `request_id` if it is urgent. #### `queue_enqueue_failed` `code: internal_server_error` · `retryable: true` The prediction could not be placed on the queue. **What to do:** submit the request again. #### `reaped_stalled` `code: internal_server_error` · `retryable: true` The prediction stalled and was terminated. **What to do:** submit the request again. #### `worker_process_fault` `code: internal_server_error` · `retryable: true` The worker processing this prediction hit an internal fault on our side and restarted. **What to do:** submit the request again. ### Unknown values **Both `code` and `reason` are open sets.** New values are added as we classify more failures, without a breaking-change announcement. Your integration must not crash, drop the error, or reject the payload when it sees a value it does not recognise. * **Unknown `reason`** — treat it as if `reason` were absent, and decide using `retryable` (or the per-code default). Keep the raw value in your logs; it is the fastest way for support to identify the failure. * **Unknown `code`** — treat it as `internal_server_error`. One sentinel value is worth knowing by name: `unknown_error_code`, which Sunra emits when a prediction failed but no classification could be recovered for it. ### Migration note: error is null when there is no error **Effective 17 August 2026.** The `error` object has one representation on every channel. Three differences between them are gone: * **`error` is `null` when the prediction did not fail.** `GET /v1/predictions/{prediction_id}` used to answer with an empty object — `"error": {}` — on every succeeded, queued and cancelled prediction. It now answers `"error": null`, which is what the queue status endpoint already did. If you test for emptiness (`Object.keys(response.error).length === 0`), replace that with a null check (`response.error === null`). * **A failure always carries a non-empty `code` and `message`.** Where a failure recorded something we cannot classify, you now get the `unknown_error_code` sentinel and a generic message, rather than a field that is missing, empty or not a string. * **Failures recorded as plain text reach you as text.** A handful of predictions from April 2026 stored their error as a bare string. `GET /v1/predictions/{prediction_id}` used to drop it, and the queue status endpoint used to replace it with the sentinel message; both now publish it as `message` under `code: "unknown_error_code"`. Nothing was removed and no new field appeared: `error` was already on every one of these responses, and only its value changed. Webhook deliveries are untouched — they were the shape the other channels converged on — and `error` continues to be absent from a `succeeded` delivery rather than sent as `null`. **Compatibility window.** There is no dual-emission period; the API stopped producing `"error": {}` on that date. If you replay or reprocess payloads you captured before it, treat `null` and `{}` as the same thing until that stored data ages out. ### Upcoming change: some failures will get a more specific `code` Today a large share of failures are reported as `service_provider_error`, including many that are neither transient nor the provider's fault. As we classify providers one at a time, those failures move to the `code` that actually describes them: * a provider deterministically rejecting your input becomes `invalid_input` (previously `service_provider_error`); * a problem on Sunra's side, such as a misconfigured endpoint or our own provider account, becomes `internal_server_error` (previously `service_provider_error`); * an input file we could not fetch becomes `invalid_input` (previously `internal_server_error`). No code is being renamed or removed, and no new `code` value is being introduced — the five values above remain the complete set of *classified* codes. What changes is which of them a given failure receives, and it changes gradually, per provider. If you branch on `code`, this is a good moment to move that logic onto `retryable` and `reason`, which describe the failure directly and are not affected by the reclassification. ## API request errors | Error Type | Error Code | HTTP Status | Description | | - | - | - | - | | authentication\_error | `INVALID_CREDENTIALS` | 401 | The username or password provided is incorrect | | authentication\_error | `TOKEN_EXPIRED` | 401 | The authentication token has expired and needs to be renewed | | authorization\_error | `ACCOUNT_NOT_ACTIVE` | 403 | The user account is inactive or suspended | | authorization\_error | `API_KEY_INACTIVE` | 403 | The API key is disabled or suspended | | authorization\_error | `FROZEN_CREDIT_OPERATION_NOT_ALLOWED` | 403 | Cannot perform operations on frozen credits in this state | | authorization\_error | `INSUFFICIENT_PERMISSION` | 403 | The user lacks the required permissions for this operation | | authorization\_error | `MODEL_ACCESS_DENIED` | 403 | Access to the requested model is not allowed for this user | | authorization\_error | `PREDICTION_ACCESS_DENIED` | 403 | Access to the requested prediction is not allowed for this user | | authorization\_error | `PRICING_OPERATION_NOT_ALLOWED` | 403 | The pricing operation is not allowed in the current context | | client\_error | `PAYMENT_PROCESSING_ERROR` | 402 | Payment could not be processed due to payment method issues | | client\_error | `PREDICTION_CREDITS_ERROR` | 402 | Insufficient credits to reserve for the prediction request | | conflict\_error | `CREDIT_PACKAGE_UNAVAILABLE` | 409 | The credit package is not available for purchase | | conflict\_error | `DUPLICATE_API_KEY_NAME` | 409 | An API key with this name already exists in the organization | | conflict\_error | `DUPLICATE_INVITATION` | 409 | An active invitation for this user already exists | | conflict\_error | `INVALID_FROZEN_CREDIT_STATUS` | 409 | The frozen credit is not in the expected state for this operation | | conflict\_error | `INVALID_PAYMENT_STATUS` | 409 | The payment is not in the correct state for this operation | | conflict\_error | `INVALID_SUBSCRIPTION_STATUS_TRANSITION` | 409 | Cannot change subscription from current state to requested state | | conflict\_error | `MODEL_ENDPOINT_ALREADY_EXISTS` | 409 | A model endpoint with this configuration already exists | | conflict\_error | `PAYMENT_ALREADY_PROCESSED` | 409 | The payment has already been processed and cannot be modified | | conflict\_error | `PREDICTION_CANNOT_BE_CANCELED` | 409 | The prediction is in a state that cannot be canceled | | conflict\_error | `PREDICTION_INVALID_STATUS` | 409 | The prediction is not in the expected state for this operation | | conflict\_error | `PREDICTION_STALLED` | 409 | The prediction has been stalled for too long and needs attention | | conflict\_error | `QUEUE_ITEM_CANNOT_BE_CANCELED` | 409 | The queued prediction cannot be canceled in its current state | | conflict\_error | `SERVICE_PROVIDER_ALREADY_EXISTS` | 409 | A service provider with this name already exists | | conflict\_error | `TRANSACTION_ALREADY_VOIDED` | 409 | The transaction has already been voided | | conflict\_error | `TRANSACTION_NOT_VOIDED` | 409 | The transaction is not in a voided state | | gone\_error | `CREDIT_EXPIRED` | 410 | The credit bucket has expired and is no longer available | | gone\_error | `INVITATION_EXPIRED` | 410 | The invitation has expired and is no longer valid | | invalid\_request\_error | `CHECKOUT_SESSION_CREATION_ERROR` | 400 | Failed to create payment checkout session | | invalid\_request\_error | `CODE_VERIFICATION_FAILED` | 400 | The verification code is invalid or has expired | | invalid\_request\_error | `CREDIT_AMOUNT_VALIDATION_ERROR` | 400 | The credit amount provided is invalid | | invalid\_request\_error | `CURRENCY_MISMATCH` | 400 | The currency does not match the expected currency | | invalid\_request\_error | `FREEZE_AMOUNT_NOT_SET` | 400 | Pricing configuration is required before making predictions | | invalid\_request\_error | `HIGH_PRECISION_DECIMAL_VALIDATION_ERROR` | 400 | The decimal value provided is invalid or has too many decimal places | | invalid\_request\_error | `INIT_PAYLOAD_QUEUE_NAME_MISSING` | 400 | The queue name is required in the initialization payload | | invalid\_request\_error | `INVALID_CREDIT_AMOUNT` | 400 | The credit amount specified is invalid | | invalid\_request\_error | `INVALID_PAYMENT_AMOUNT` | 400 | The payment amount is invalid | | invalid\_request\_error | `INVALID_PAYMENT_METHOD` | 400 | The payment method provided is not valid | | invalid\_request\_error | `INVALID_SUBSCRIPTION_DATA` | 400 | The subscription data provided contains invalid information | | invalid\_request\_error | `INVALID_SUBSCRIPTION_OPERATION` | 400 | The subscription operation is not valid | | invalid\_request\_error | `INVALID_VOID_METADATA` | 400 | Required void metadata is missing or invalid | | invalid\_request\_error | `MISSING_PAYMENT_METHOD` | 400 | A payment method is required for this operation | | invalid\_request\_error | `MODEL_ENDPOINT_PRICE_ITEM_INVALID` | 400 | The model endpoint pricing configuration is invalid | | invalid\_request\_error | `MODEL_ENDPOINT_PRICE_TYPE_NOT_SUPPORTED` | 400 | The pricing type is not supported for this model endpoint | | invalid\_request\_error | `PAYMENT_VALIDATION_ERROR` | 400 | The payment information failed validation | | invalid\_request\_error | `PREDICTION_INVALID_INPUT` | 400 | The input provided for the prediction is invalid | | invalid\_request\_error | `PREDICTION_FAILED` | 400 | The prediction whose output you requested ended in `status: "failed"`. `error.details` carries the [prediction failure](#reading-a-failure-from-the-result-endpoint). | | invalid\_request\_error | `PRICE_TYPE_INVALID` | 400 | The pricing type specified is not valid | | invalid\_request\_error | `PRICE_USAGE_KEY_MISSING` | 400 | A usage key is required for this pricing type | | invalid\_request\_error | `PRICE_USAGE_VALUE_INVALID` | 400 | The usage value provided is invalid | | invalid\_request\_error | `PRICING_VALIDATION_ERROR` | 400 | The pricing configuration failed validation | | invalid\_request\_error | `SUBSCRIPTION_OPERATION_ERROR` | 400 | The subscription operation failed | | not\_found\_error | `API_KEY_NOT_FOUND` | 404 | The specified API key does not exist | | not\_found\_error | `CREDIT_BUCKET_NOT_FOUND` | 404 | The specified credit bucket does not exist | | not\_found\_error | `CREDIT_PACKAGE_NOT_FOUND` | 404 | The specified credit package does not exist | | not\_found\_error | `CUSTOMER_PROFILE_NOT_FOUND` | 404 | The customer profile could not be found | | not\_found\_error | `FROZEN_CREDIT_NOT_FOUND` | 404 | The frozen credit record could not be found | | not\_found\_error | `INVITATION_NOT_FOUND` | 404 | The specified invitation does not exist | | not\_found\_error | `MODEL_ENDPOINT_NOT_FOUND` | 404 | The specified model endpoint does not exist | | not\_found\_error | `MODEL_ENDPOINT_PRICE_ITEM_NOT_FOUND` | 404 | The pricing configuration for this model endpoint does not exist | | not\_found\_error | `MODEL_ENDPOINT_PRICE_NOT_FOUND` | 404 | No pricing found for the specified model endpoint | | not\_found\_error | `MODEL_NOT_FOUND` | 404 | The specified model does not exist | | not\_found\_error | `MODEL_NOT_FOUND` | 404 | The specified model does not exist | | not\_found\_error | `MODEL_PROVIDER_NOT_FOUND` | 404 | The specified model provider does not exist | | not\_found\_error | `MODEL_PROVIDER_NOT_FOUND` | 404 | The specified model provider does not exist | | not\_found\_error | `ORGANIZATION_MEMBER_NOT_FOUND` | 404 | The specified organization member does not exist | | not\_found\_error | `ORGANIZATION_NOT_FOUND` | 404 | The specified organization does not exist | | not\_found\_error | `PAYMENT_METHOD_NOT_FOUND` | 404 | The specified payment method does not exist | | not\_found\_error | `PAYMENT_NOT_FOUND` | 404 | The specified payment does not exist | | not\_found\_error | `PREDICTION_NOT_FOUND` | 404 | The specified prediction does not exist | | not\_found\_error | `PREDICTION_NOT_FOUND` | 404 | The specified prediction does not exist | | not\_found\_error | `PRICE_NOT_FOUND` | 404 | The specified price configuration does not exist | | not\_found\_error | `QUEUE_ITEM_NOT_FOUND` | 404 | The specified queue item does not exist | | not\_found\_error | `SERVICE_PROVIDER_NOT_FOUND` | 404 | The specified service provider does not exist | | not\_found\_error | `SERVICE_PROVIDER_NOT_FOUND` | 404 | The specified service provider does not exist | | not\_found\_error | `SUBSCRIPTION_NOT_FOUND` | 404 | The specified subscription does not exist | | not\_found\_error | `TASK_LOG_NOT_FOUND` | 404 | The specified task log does not exist | | not\_found\_error | `TRANSACTION_NOT_FOUND` | 404 | The specified transaction does not exist | | not\_found\_error | `USER_NOT_FOUND` | 404 | The specified user does not exist | | precondition\_failed\_error | `INSUFFICIENT_CREDIT` | 412 | Insufficient credits available for this operation | | service\_unavailable\_error | `PREDICTION_UNAVAILABLE` | 503 | The prediction service is currently unavailable | | service\_unavailable\_error | `STRIPE_OPERATION_ERROR` | 503 | The payment service is currently unavailable | | unprocessable\_entity\_error | `MODEL_ENDPOINT_PRICE_ITEM_MATCH_FAILED` | 422 | Cannot find matching pricing for the model endpoint configuration | | unprocessable\_entity\_error | `PREDICTION_UNSAFE_CONTENT` | 422 | The prediction input contains unsafe or inappropriate content | | unprocessable\_entity\_error | `PRICE_CALCULATION_ERROR` | 422 | Unable to calculate pricing for the requested configuration | ## Failures that carry no error object Every error above is produced by Sunra and arrives as a JSON `error` object. A request can also fail at the CDN edge in front of the API, before it ever reaches Sunra — those failures carry no JSON body, so client code that assumes one will fail while parsing rather than reporting what happened. | What you see | Cause | | - | - | | `403` with a plain-text `error code: 1010` body | The request's `User-Agent` matched a blocked signature at the edge — default HTTP-library strings such as `Python-urllib/3.x` are affected. Send a descriptive `User-Agent` naming your application. | | `524` with no body | The response outlived the edge's timeout (the CDN default is 125 seconds; cuts have been measured from \~112s). On LLM endpoints this means a non-streaming generation ran too long — see [Output limits](/llm/limits#non-streaming-requests-the-edge-timeout). | | Connection closed with no response (`RemoteDisconnected`, `ECONNRESET`, `SSL: UNEXPECTED_EOF_WHILE_READING`) | The same edge timeout, on a connection where the edge dropped the socket instead of synthesizing a `524`. Timing and connection reuse decide which of the two you get. | | `520`, `522`, or `524` with a plain-text `error code: NNN` body on a request that carried a large body (such as a long chat history) — including well before any generation could have finished | The request body never finished uploading. The platform allows 60 seconds between successive chunks of a request body; when an upload stalls past that, the request is abandoned and the edge reports the failed exchange as one of its plain-text errors. A request that fails this way was never processed and never billed, so retrying it is always safe. Stalls typically come from a congested uplink or from uploading many large request payloads concurrently. | The distinguishing signal is the shape of the failure, not its status code: a Sunra error is always JSON with an `error` object, and anything else came from in front of Sunra. A plain-text body or a bare connection drop that reproduces only on long-running or unusually-shaped requests is an edge policy, not a network fault and not an outage. # File Upload Source: https://docs.sunra.ai/platform/file-upload Sunra models take their file inputs as URLs — the audio you want transcribed, the image you want animated. When the file only exists on your machine or in a private bucket, upload it to Sunra storage first: you get back an `assets.sunra.ai` URL that you can pass straight into any model endpoint. Uploading takes two requests: 1. **Initiate** — `POST /v1/storage/upload/initiate` with the file name and content type. Sunra replies with a pre-signed URL to upload to, plus the final public URL the file will have. 2. **Upload** — `PUT` the bytes to that pre-signed URL. The file is readable at its public URL as soon as the `PUT` succeeds — there is no processing step to wait for. ## Authentication Only the first request is authenticated, with the same API key you use everywhere else on `api.sunra.ai`: ```bash theme={null} Authorization: Key $SUNRA_KEY ``` The second request carries its authorization inside the pre-signed URL, so it needs no API key — which is what makes it safe to hand the `upload_url` to a browser or a mobile client. See [Authentication](/platform/authentication) for how to obtain and store the key. ## Step 1 — Initiate the upload ```bash theme={null} curl -X POST https://api.sunra.ai/v1/storage/upload/initiate \ -H "Authorization: Key $SUNRA_KEY" \ -H "Content-Type: application/json" \ -d '{"file_name": "chunk10.mp3", "content_type": "audio/mpeg"}' ``` ### Request body | Field | Type | Required | Description | | - | - | - | - | | `file_name` | string | yes | Name of the file **including its extension**, for example `chunk10.mp3`. Only the extension is carried over to the public URL. Pass a bare file name: path separators are rejected, so `audio/chunk10.mp3` is a `400`. | | `content_type` | string | yes | MIME type of the file, for example `audio/mpeg`. You must send this same value as the `Content-Type` header when you upload. | ### Response `201 Created`: ```json theme={null} { "upload_url": "https:///uploads/2f8c1d9e-6b4a-4d21-9f0e-7c3ab5d81e64.mp3?X-Amz-Algorithm=...&X-Amz-Signature=...", "file_url": "https://assets.sunra.ai/uploads/2f8c1d9e-6b4a-4d21-9f0e-7c3ab5d81e64.mp3" } ``` | Field | Description | | - | - | | `upload_url` | Pre-signed `PUT` URL for the object store. Valid for **1 hour** (3600 seconds) from the moment it is issued. | | `file_url` | Public URL the file will have once uploaded. This is the value you pass to model endpoints. | Each call mints a fresh, randomly named object — the name you send is never used as the object name, so uploads can never collide with or overwrite each other. Only the extension survives, lower-cased: `Chunk10.MP3` lands at `…/.mp3`. ### Rejected file names Both of these are `400` and create nothing, so correct the name and call again: | Error message | Cause | | - | - | | `Invalid filename` | Empty, longer than 255 characters, or containing a path separator or a control character — for example `nested/file.mp3` or `../escape.mp3`. | | `Invalid filename extension` | No extension at all (`recording`), a name that is only an extension (`.mp3`), or an extension that is not 1–10 alphanumeric characters. | Multi-part extensions keep only the last segment: `archive.tar.gz` uploads fine and lands at `…/.gz`. ## Step 2 — Upload the file `PUT` the raw bytes to `upload_url`, repeating the content type you declared: ```bash theme={null} curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: audio/mpeg" \ --data-binary @chunk10.mp3 ``` The `Content-Type` header is part of what was signed. If it differs from the `content_type` you sent to `/upload/initiate` — even by case or by a stray charset parameter — the signature no longer matches and the object store rejects the upload. Do not add an `Authorization` header here. ## Step 3 — Use the file URL `file_url` is a normal public URL. Pass it to any model endpoint that accepts a file input — here, speech-to-text: ```bash theme={null} curl -X POST https://api.sunra.ai/v1/queue/bytedance/seed-asr-2.0/speech-to-text \ -H "Authorization: Key $SUNRA_KEY" \ -H "Content-Type: application/json" \ -d '{"audio": "https://assets.sunra.ai/uploads/2f8c1d9e-6b4a-4d21-9f0e-7c3ab5d81e64.mp3"}' ``` That returns a `request_id` like any other submission; poll or stream it as described in [Queue](/multimodal/queue). ## Full example Upload a local audio file and transcribe it: ```bash Curl theme={null} #!/usr/bin/env bash set -euo pipefail FILE="chunk10.mp3" CONTENT_TYPE="audio/mpeg" # 1. Ask for a pre-signed URL RESPONSE=$(curl -sS --fail-with-body -X POST https://api.sunra.ai/v1/storage/upload/initiate \ -H "Authorization: Key $SUNRA_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg name "$(basename "$FILE")" --arg type "$CONTENT_TYPE" \ '{file_name: $name, content_type: $type}')") UPLOAD_URL=$(echo "$RESPONSE" | jq -r .upload_url) FILE_URL=$(echo "$RESPONSE" | jq -r .file_url) # 2. Upload the bytes curl -sS --fail-with-body -X PUT "$UPLOAD_URL" \ -H "Content-Type: $CONTENT_TYPE" \ --data-binary "@$FILE" # 3. Feed the public URL to a model curl -sS --fail-with-body -X POST https://api.sunra.ai/v1/queue/bytedance/seed-asr-2.0/speech-to-text \ -H "Authorization: Key $SUNRA_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg url "$FILE_URL" '{audio: $url}')" ``` ```python Python theme={null} import os import requests API_KEY = os.environ["SUNRA_KEY"] BASE_URL = "https://api.sunra.ai" def upload_file(path: str, content_type: str) -> str: """Upload a local file to Sunra storage and return its public URL.""" initiated = requests.post( f"{BASE_URL}/v1/storage/upload/initiate", headers={"Authorization": f"Key {API_KEY}"}, json={ "file_name": os.path.basename(path), "content_type": content_type, }, ) initiated.raise_for_status() upload_url = initiated.json()["upload_url"] file_url = initiated.json()["file_url"] with open(path, "rb") as handle: uploaded = requests.put( upload_url, data=handle, headers={"Content-Type": content_type}, ) uploaded.raise_for_status() return file_url audio_url = upload_file("chunk10.mp3", "audio/mpeg") submitted = requests.post( f"{BASE_URL}/v1/queue/bytedance/seed-asr-2.0/speech-to-text", headers={ "Authorization": f"Key {API_KEY}", "Content-Type": "application/json", }, json={"audio": audio_url}, ) submitted.raise_for_status() print(submitted.json()) ``` ## Things to know * **The extension is required, and it is load-bearing.** It is the one part of `file_name` that survives into `file_url`, and some models — speech-to-text in particular — read the media format off the URL path. Send the real extension for the file's actual format; a WAV file named `.mp3` will reach the model mislabelled. * **`upload_url` expires after one hour.** It is a signed URL, not a reservation: if it expires before you finish, call `/upload/initiate` again. Each call mints a fresh object, so the retry gets a new `file_url` too. * **Content type must match on both requests.** The value in `content_type` and the `Content-Type` header of the `PUT` have to be identical. * **Uploaded files are publicly readable.** `file_url` is unguessable, but it is not secret and it is not time-limited — anyone who has the URL can fetch the file. Do not upload anything confidential, and treat the URLs themselves as sensitive if the content is. * **Files are not tied to a request.** Once uploaded, a `file_url` can be reused across as many predictions as you like; it is not consumed by the first model that reads it. # Pricing Source: https://docs.sunra.ai/platform/pricing Every model page on [sunra.ai](https://sunra.ai/models) shows the price of each endpoint next to the model name — for example `$0.045-$0.09 per image`. Hovering the price shows a tooltip with the exact rule, such as which resolutions or options change the price. For most models the price of a request is **fixed at submission time**: it is computed from your request parameters before the model runs, and that exact amount is what appears in your [billing history](https://sunra.ai/dashboard/billing). The same request parameters always cost the same amount, so you can budget deterministically. A few models (for example video models billed per output second) settle after completion based on actual usage; their tooltips say so. ## Pricing API You can read prices programmatically — both as human-readable strings and as machine-readable rules. | Endpoint | Auth | Description | | - | - | - | | api.sunra.ai/v1/pricing | API key | Lists all models with `display` and `tooltip` price strings | | api.sunra.ai/v1/models | none | Lists all models; gives you the model `id` | | api.sunra.ai/v1/pricing/model/\{model\_id}/endpoint/\{endpoint} | none | Full machine-readable price rules for one endpoint | ### Paging the pricing list `GET /v1/pricing` returns a list envelope — `{ "object": "list", "data": [...], "has_more": … }` — and takes: | Parameter | Meaning | | - | - | | `limit` | Rows per page, 1–10000. Defaults to 10 | | `starting_after` | The `id` of the last row you received; returns the rows after it | | `model_ids` | Comma-separated model ids to restrict the list to | Page forward until `has_more` is `false`: ```bash theme={null} curl -s -H "Authorization: Key $SUNRA_KEY" \ "https://api.sunra.ai/v1/pricing?limit=100&starting_after=mep_b1Mzl6kT3znJ" ``` A `starting_after` that is not the `id` of a row this endpoint returns is a `400` — it is never treated as "start from the beginning". `ending_before` is not supported; page forward instead. If you just want the whole catalog in one call, a large `limit` works too. To compute the exact price of a request before sending it, first look up the model `id`: ```bash theme={null} curl -s "https://api.sunra.ai/v1/models?limit=300" \ | jq '.data[] | select(.alias == "bytedance/seedream-5.0-pro") | .id' # "model_b63ac71d3992" ``` Then fetch the price rules for the endpoint you plan to call: ```bash theme={null} curl -s "https://api.sunra.ai/v1/pricing/model/model_b63ac71d3992/endpoint/image-editing" ``` ```json theme={null} { "id": "mep_e5e2c1437feb", "object": "model_endpoint_price", "model_name": "seedream-5.0-pro", "model_endpoint": "image-editing", "display": "$0.045-$0.126 per image", "tooltip": "1K: $0.045, 2K: $0.09 per image, +$0.004 per reference image from the 2nd", "type": "pre_defined", "prices": [ { "unit_amount": "0.045", "matches": { "resolution": "1K", "images": { "$size_lte": 1 } } }, { "unit_amount": "0.049", "matches": { "resolution": "1K", "images": { "$size": 2 } } }, { "unit_amount": "0.09", "matches": { "images": { "$size_lte": 1 } } }, { "unit_amount": "0.094", "matches": { "images": { "$size": 2 } } }, { "unit_amount": "0.126" } ] } ``` ## How price rules are evaluated The `prices` array is evaluated **top to bottom against your request payload**, and the first row whose `matches` conditions all hold determines the price. A row without `matches` always matches — it is the fallback. Conditions compare your request parameters: | Condition | Meaning | | - | - | | `"resolution": "1K"` | Parameter equals the value exactly | | `"duration": { "$gte": 5, "$lte": 10 }` | Numeric parameter is in range (`$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`) | | `"images": { "$size": 3 }` | Array parameter has exactly 3 items (`$size_gt`, `$size_gte`, `$size_lt`, `$size_lte` for ranges) | A parameter that is **absent from your request never matches a condition** — defaults are not filled in before matching. Price tables are ordered so that omitting an optional parameter lands on the row for its default behavior. Worked example, using the table above: * `{"images": [a], "resolution": "1K"}` → first row → **\$0.045** * `{"images": [a, b, c], "resolution": "1K"}` → 1K row with `$size: 3` → **\$0.053** * `{"images": [a]}` (resolution omitted, model defaults to 2K) → skips the 1K rows, hits `$size_lte: 1` → **\$0.09** If a matched row has a `usage_key`, the amount is `unit_amount × ` that parameter's value (for example price per second of requested duration). ## Seedance video token pricing The Seedance 2.0 family (including the `fast`, `mini` and relay variants) and Seedance 2.5 are billed **per token**, not per output second. Their price rule is `"type": "post_calculated"`: the amount is settled after generation from the usage the model reports. `total_tokens` in the prediction output is the authoritative quantity, and the charge is `total_tokens × rate`, with the rate quoted per 1M tokens. The rate depends on the **output resolution** and on **whether the request carries reference videos** — requests with input video are billed at a lower rate for the whole request. The rates per model are in the price tooltip on the model page and in the `tooltip` field of `GET /v1/pricing/model/{model_id}/endpoint/reference-to-video`. For these endpoints the machine-readable `prices` array carries only the settlement unit, because the token count does not exist until the model has finished generating. ### How tokens are counted ```text theme={null} tokens = floor(frames × width × height / 1024) frames = 24 × output_seconds + 1 # no reference video frames = 24 × output_seconds + 1 + 24 × billed_input_seconds # with reference videos ``` `width × height` is the resolution of the video you actually receive. With `ratio: adaptive` that follows your input, so it is not necessarily the aspect ratio you would assume from the resolution setting — read it from the output file if you want to reproduce a charge exactly. The frame count is accurate to about one frame (the upstream occasionally returns one fewer); `total_tokens` is always what you are billed on. Reference images and reference audio add no tokens. Only reference **videos** do. ### Billed input seconds Reference video is not billed for its literal length. The billed input duration is computed per task: ```text theme={null} billed_input_seconds = max(floor(total actual input seconds), ceil(output_seconds × 2/3)) ``` A short reference video with a long output still pays a floor of two thirds of the output duration — a 6 s clip on a 30 s output is billed as 20 s of input. Input seconds are summed across all reference videos and floored to whole seconds; the frame rate, resolution and encoding of the source video do not matter. **Rare exception.** For a small share of reference videos the upstream model counts more input than the clip contains — we have seen 17 s clips billed as 30 s (720 input frames) on Seedance 2.5, with the same clip re-encoded at 24 or 30 fps, at other resolutions or without audio billed the same way, and the same footage cut down to its first 8 s billed normally. The trigger is the video content itself, not any request parameter, so it cannot be predicted before generation. `total_tokens` in the prediction output is always what you are billed on. ### Worked examples Seedance 2.5, 720p 16:9 output — 1280 × 720 = 900 tokens per frame — with a reference video, at \$6.40 per 1M tokens: | Output | Reference video | Billed input | Frames | Tokens | Amount | | - | - | - | - | - | - | | 10 s | 6 s | 7 s (minimum) | 241 + 168 = 409 | 368,100 | \$2.36 | | 30 s | 6 s | 20 s (minimum) | 721 + 480 = 1,201 | 1,080,900 | \$6.92 | | 5 s | 15 s | 15 s (actual) | 121 + 360 = 481 | 432,900 | \$2.77 | | 30 s | 24 s | 24 s (actual) | 721 + 576 = 1,297 | 1,167,300 | \$7.47 | ### Draft mode (Seedance 2.5) Seedance 2.5 can render a cheap **480p draft** first and later upscale exactly that draft — same seed, same prompt and references — to **1080p** without re-describing it: 1. Call `text-to-video`, `image-to-video` or `reference-to-video` with `"draft": true` and `"resolution": "480p"` (draft mode accepts no other resolution). The output carries a `draft_task_id`, valid for 7 days. 2. Call `bytedance/seedance-2.5/draft-to-video` with that `draft_task_id` (plus an optional `output_format`). The result is always 1080p; duration, ratio, audio and references are reused from the draft and cannot be changed. Both steps are billed exactly like an ordinary request at their resolution: the draft at the 480p rate, the final at the 1080p rate. The final re-counts the draft's reference-video seconds, so a draft made with reference videos settles its final at the **with input video** 1080p rate (\$7.00 per 1M tokens on Seedance 2.5) and one made without at the **without input video** rate (\$11.70). As always, `total_tokens` in each output is the billed quantity. Draft mode is supported by BytePlus and Sunra Alkalurops. When you call `draft-to-video` without `service_provider_name` or `provider.only` / `order` / `ignore`, Sunra uses the latest successful prediction with that `draft_task_id` in your organization to route the final video back to the original channel. Explicit provider choices take priority. If no matching record is found or its channel is unavailable, normal default routing applies. Draft IDs are bound to the upstream account, so a different channel may not be able to finalize them. Keep `draft_task_id` private; automatic source lookup is limited to your organization. ## MiniMax H3 family per-second pricing The four official MiniMax H3 models are billed **per second of output video**, at a rate that depends on the output resolution you ask for: | Model | 480P | 768P | 2K | Duration | Default resolution | Reference-to-video | | - | - | - | - | - | - | - | | `minimax/minimax-h3` | \$0.038/s | \$0.08/s | \$0.13/s | 4–15 s | 2K | yes | | `minimax/minimax-h3-max` | \$0.05/s | \$0.08/s | — | 5–15 s | 768P | yes | | `minimax/minimax-h3-fast` | \$0.046/s | — | — | 4–15 s | 480P | yes | | `minimax/minimax-h3-max-turbo` | \$0.025/s | \$0.04/s | — | 5–15 s | 768P | no | For **text-to-video** and **image-to-video** the amount is fixed at submission (`"type": "pre_defined"`): it is the rate of the selected tier multiplied by the `duration` you request, in whole seconds. A 5 s 768P clip on `minimax/minimax-h3` costs 5 × \$0.08 = \$0.40; the same clip at 2K costs \$0.65. The `resolution` parameter picks the tier. Omitting it uses the model's default resolution shown above — the price table is ordered so that a request without `resolution` lands on the default tier's rows (see [How price rules are evaluated](#how-price-rules-are-evaluated)). `minimax/minimax-h3-fast` serves 480P only and has no `resolution` parameter. `minimax/minimax-h3-max-turbo` has no reference-to-video endpoint; calling it returns `404 MODEL_ENDPOINT_NOT_FOUND`. ### Reference-to-video input materials **reference-to-video** is settled after completion (`"type": "post_calculated"`) from the usage MiniMax reports, because reference material is billed on top of the output: ```text theme={null} amount = output_seconds × output tier rate + reference_video_secs × reference video rate + max(0, reference_images − free images) × per-image price ``` Reference **audio** is free on every model. The reference rules differ per model: | Model | Free reference images | Each further image | Reference video per second | | - | - | - | - | | `minimax/minimax-h3` | 5 | \$0.04 | same as the output tier — 480P \$0.038 · 768P \$0.08 · 2K \$0.13 | | `minimax/minimax-h3-fast` | 5 | \$0.04 | \$0.046 | | `minimax/minimax-h3-max` | 2 | \$0.074 | 480P \$0.0553 · 768P \$0.143 | Note that `minimax/minimax-h3-max` bills reference video at its own rate table, which is higher than its output rate — reference video is the dominant cost in a reference-to-video request on that model. ### H3 worked example `minimax/minimax-h3-max` reference-to-video at 768P, 5 s of output, one 5 s reference video and 3 reference images: | Component | Quantity | Rate | Amount | | - | - | - | - | | Output | 5 s | \$0.08/s | \$0.40 | | Reference video | 5 s | \$0.143/s | \$0.715 | | Reference images | 3 − 2 free = 1 | \$0.074 | \$0.074 | | | | **Total** | **\$1.189** | ## Topaz Hyperion 2.5 SDR-to-HDR pricing `topazlabs/hyperion-2.5/sdr-to-hdr` is billed **per frame**, in credits of \$0.10, and settled after completion (`"type": "post_calculated"`). Hyperion keeps the resolution, frame rate and duration of your input, so the charge is fully predictable from the input file: ```text theme={null} frames_per_credit = 13.02 if width × height ≤ 1920 × 1080 = 5.9 otherwise (above 1080p, up to 4K) credits = ceil( ceil(seconds) × fps / frames_per_credit ) amount = credits × $0.10 ``` `seconds` is the container duration rounded **up** to a whole second, and `fps` is the stream's frame rate. For variable-frame-rate files the higher of the average and nominal rate is used, so re-encode to a constant frame rate if you want the lowest bill. ### Input limits A request is checked before it is submitted upstream and rejected with `input_validation_failed` — **not billed** — if the video exceeds any of these: | Limit | Value | | - | - | | Resolution | ≤ 3840 px on either axis and ≤ 3840 × 2160 pixels | | Frame rate | ≤ 60 fps | | Duration | ≤ 5 minutes | | Size | width × height × seconds ≤ 130,000,000 | | Estimated cost | ≤ \$30 per request | Only `http(s)://` video URLs are accepted; `data:` URIs are not. ### Hyperion worked examples | Input | Rounded seconds × fps | Frames per credit | Credits | Amount | | - | - | - | - | - | | 1280 × 720, 24 fps, 10.04 s | 11 × 24 = 264 | 13.02 | 21 | \$2.10 | | 1280 × 720, 24 fps, 15.04 s | 16 × 24 = 384 | 13.02 | 30 | \$3.00 | | 1920 × 1080, 30 fps, 60 s | 60 × 30 = 1,800 | 13.02 | 139 | \$13.90 | | 3840 × 2160, 24 fps, 15 s | 15 × 24 = 360 | 5.9 | 62 | \$6.20 | ## Topaz video-upscaler pricing `topazlabs/video-upscaler/video-upscale` is billed **per second of output video** and settled after completion (`"type": "post_calculated"`). The per-second rate is set by the size of the **output** frame, so `upscale_factor` is what moves the bill: ```text theme={null} rate = $0.01/s if output_width × output_height ≤ 1280 × 720 = $0.02/s if output_width × output_height ≤ 1920 × 1080 = $0.08/s otherwise amount = ceil(output_seconds) × rate × 2 if enhance_model is a Starlight variant × 0.5 if enhance_model is "Gaia 2" ``` Three details are worth reading twice: * **The tier is the pixel count, not the height.** A 1632 × 672 output is 1.10 megapixels, so it bills at the 1080p rate even though it is only 672 px tall; an 820 × 852 output is 0.70 megapixels and bills at the 720p rate even though it is 852 px tall. * **Seconds round up.** A 5.038-second output is 6 billable seconds. * **Frame interpolation is free.** Setting `target_fps` does not change the price, at any value. | Enhance model | Multiplier | | - | - | | `Starlight Precise 1/2/2.5`, `Starlight HQ`, `Starlight Mini`, `Starlight Sharp`, `Starlight Fast 1/2` | × 2 | | `Gaia 2` | × 0.5 | | `Proteus`, `Artemis HQ/MQ/LQ`, `Nyx`, `Nyx Fast`, `Nyx XL`, `Nyx HF`, `Gaia HQ`, `Gaia CG` | × 1 | ### Video upscaler worked examples | Output | Enhance model | Billed seconds | Rate | Amount | | - | - | - | - | - | | 1280 × 720, 10 s | `Proteus` | 10 | \$0.01/s | \$0.10 | | 1632 × 672, 5.038 s | `Starlight HQ` | 6 | \$0.02/s × 2 | \$0.24 | | 3840 × 2160, 30 s | `Gaia 2` | 30 | \$0.08/s × 0.5 | \$1.20 | ## Qwen Image Layered pricing `qwen/qwen-image-layered/image-to-image` returns one image per layer and is billed \*\*$0.05 per output layer**, so the price is `$0.05 × num\_layers`. `num\_layers` accepts 1–10 and defaults to 4, which makes the default request $0.20. Because the count is an input, the price is known before the job runs (`"type": "pre\_defined"\`). ## FLUX.2 \[pro] image-to-image pricing `black-forest-labs/flux-2-pro/image-to-image` is billed **per processed megapixel** and settled after completion (`"type": "post_calculated"`). One megapixel is 1024 × 1024 = 1,048,576 pixels. ```text theme={null} megapixels = (number of reference images) # 1 MP each, whatever their size + ceil(output_width × output_height / 1,048,576) amount = $0.03 + $0.015 × (megapixels − 1) ``` Two things are worth knowing before you upload: * **A reference image always costs exactly 1 megapixel**, however large it is — the model downsamples it, and there is no discount for sending a small one either. * **Each image is rounded up on its own**, so a 1536 × 1024 result (1.5 MP) counts as 2 MP. `image_urls` accepts up to 8 reference images. `text-to-image` is unaffected: all of its aspect ratios fit in one megapixel, so it stays a flat \$0.03 per image. ### FLUX.2 \[pro] worked examples | Reference images | Result | Megapixels | Amount | | - | - | - | - | | 1 × any size | 1024 × 1024 | 1 + 1 = 2 | \$0.045 | | 2 × any size | 1024 × 1024 | 2 + 1 = 3 | \$0.06 | | 2 × any size | 1536 × 1024 | 2 + 2 = 4 | \$0.075 | | 4 × 2048 × 2048 | 1024 × 1024 | 4 + 1 = 5 | \$0.09 | | 2 × any size | 1728 × 2304 (4 MP) | 2 + 4 = 6 | \$0.105 | ## gpt-image-1.5 token pricing `openai/gpt-image-1.5` is billed as a **per-image price plus the tokens you send in**, and settled after completion (`"type": "post_calculated"`). ```text theme={null} amount = per-image price (see table) + $0.008 per 1,000 input image tokens (reference images, image-editing only) + $0.005 per 1,000 prompt tokens ``` | Result size | low | medium | high | | - | - | - | - | | 1024 × 1024 (1:1) | \$0.009 | \$0.034 | \$0.133 | | 1024 × 1536 (2:3) | \$0.013 | \$0.051 | \$0.200 | | 1536 × 1024 (3:2) | \$0.013 | \$0.050 | \$0.199 | `text-to-image` has no reference images, so its bill is the table above plus the prompt. ### What a reference image costs `input_fidelity` is what drives the reference-image cost, and it defaults to `high`: | Reference image | `input_fidelity: low` | `input_fidelity: high` | | - | - | - | | square (e.g. 1024 × 1024, 2048 × 2048) | 194 tokens — \$0.0016 | 4,354 tokens — \$0.0348 | | portrait (e.g. 1024 × 1536) | 323 tokens — \$0.0026 | 6,563 tokens — \$0.0525 | | landscape (e.g. 1536 × 1024) | 323 tokens — \$0.0026 | 6,531 tokens — \$0.0522 | Resolution beyond that barely matters: the image is scaled so its shortest side is 512 px before it is tiled, which is why a 512 × 512 and a 2048 × 2048 square cost the same. A `mask_image` is **not** charged as a reference image, and the text the model writes alongside the image is not charged either. ### gpt-image-1.5 worked examples | Request | Per-image | Reference images | Amount | | - | - | - | - | | `text-to-image`, 1024 × 1024, high, 20-token prompt | \$0.133 | — | \$0.1331 | | `image-editing`, 1 square reference, high fidelity, low quality | \$0.009 | \$0.0348 | \$0.0439 | | `image-editing`, 1 portrait reference, high fidelity, high quality 1024 × 1536 | \$0.200 | \$0.0525 | \$0.2526 | | `image-editing`, 3 square references, low fidelity, high quality | \$0.133 | \$0.0047 | \$0.1377 | Only the first row includes a prompt; the other three leave it out, since a typical prompt adds well under \$0.0005. Every amount is rounded up to the next \$0.0001. ## LLM token pricing LLM models (chat, messages, responses) are billed per token rather than per request. Rates are listed on each model's page under [sunra.ai/models](https://sunra.ai/models?type=llm) and readable programmatically from `GET /v1/pricing/model/{model_id}/endpoint/llm`. A model's rate card carries one rate per token bucket. The common buckets are: | Bucket | What it covers | | - | - | | Input | Prompt tokens the provider processed fresh | | Output | Tokens the model generated, including reasoning tokens where a model bills them as output | | Cache read | Prompt tokens served from a provider's prompt cache | | Cache write | Prompt tokens written into the cache, on providers that charge for it. Models offering an extended one-hour cache (`cache_control` with `"ttl": "1h"`) price those writes separately | **Cache reads are billed at the cache-read rate and are removed from the input bucket**, so a cached token is charged once, at the lower rate. For `deepseek-v4-flash` the cache-read rate is \$0.0028 per 1M tokens against an input rate of \$0.14 — 50× cheaper; for `deepseek-v4-pro` it is \$0.003625 against \$0.435. A model with no cache-read rate on its card bills cached tokens at its input rate. You do not need to send anything to opt in — see [prompt cache hits](/llm/token-usage#prompt-cache-hits) for how hits are reported back to you. A request whose prompt is 98,695 tokens with 96,128 of them served from cache settles as three separate lines, and the input line counts only the 2,567 tokens that were not cached: | Bucket | Tokens | Rate per 1M | Amount | | - | - | - | - | | Input | 2,567 | \$0.14 | \$0.00035938 | | Output | 472 | \$0.28 | \$0.00013216 | | Cache read | 96,128 | \$0.0028 | \$0.00026916 | | | | **Total** | **\$0.00076070** | Unlike most multimodal endpoints, an LLM request cannot be priced before it runs — the token count is not known until the model answers. Credits are reserved at submission from a conservative estimate of your prompt, and the reservation is settled against actual usage when the request completes; the difference is released. Your [billing history](https://sunra.ai/dashboard/billing) shows the settled amount. Streamed requests that end early are billed for the output actually delivered to you, and requests that fail are not billed at all — see [Output limits and stream lifetime](/llm/limits#billing-for-an-aborted-stream).