Overview
An AI project rarely stalls on the model. It stalls on everything around it: every provider with its own shape, its own keys, its own limits and its own invoice; the proof of concept that runs in an afternoon and the full migration that shows up three months later, when something better lands. That part stops being yours.
The surface is compatible with what the industry already speaks — the same body you send an OpenAI or Anthropic client today works here, streaming included. What changes is what comes with it:
- One credential, every capability. Text, image, voice, video and search under the same
kriou_live_…key. No provider key ever travels in your code. - Nothing is spent before it counts. Image size, video duration, character ceiling, enabled model — all checked on the way in. A malformed request comes back in milliseconds and costs zero credits.
- The bill closes per call. Tokens, seconds, characters and calls become a debit on the wallet of the key that spent them, from real usage. No estimates, no surprise at the end of the month.
- The model improves without you rewriting anything. Each public name is a quality tier we measure and revisit. When something better shows up behind it, you get the swap — and your code stays as it is.
The root is public and doubles as a health probe: it answers without authentication and reports which build is live.
curl https://api.entelecy.ai/ \
-H "Accept: application/json"
# 200 OK
{
"service": "Entelecy.Api",
"environment": "Production",
"build": "c2-forwarding",
"forwarding": true,
"timestamp": "2026-08-11T18:55:49.231Z"
}This page is what already stands. If your product needs a route that doesn't exist, a specific model, a flow the market doesn't ship ready-made — that is work we do, and it's usually where the conversation gets interesting. Talk to us
Authentication
Every call requires an Authorization header carrying an Entelecy key as a Bearer token. The key is created and lives in your account; the gateway validates it against Entelecy Account and keeps the result in a short cache, so that check costs almost nothing on the hot path.
POST /v1/chat/completions HTTP/1.1
Host: api.entelecy.ai
Authorization: Bearer kriou_live_7Qb3xk9_M2pN-VtR4sLu8Z
Content-Type: application/jsonAccepted credentials:
kriou_live_…— production key. The normal way to integrate.kriou_test_…— test key, useful for staging. Endpoints can be configured to reject it.
A malformed, revoked or expired key returns 401 without explaining why. The key never appears in logs — not in full, not partially. If one leaks, revoke it in the account: the cached validation expires within seconds.
Conventions
Requests and responses are UTF-8 JSON, except where noted (audio and media uploads use multipart/form-data; speech synthesis returns audio bytes; transcription returns plain text). Fields the gateway doesn't know are forwarded to the provider untouched — which is what lets you use new features before they show up on this page.
Headers
| Header | Where | Description |
|---|---|---|
| Authorization | request | Bearer + Entelecy key. Required on every endpoint except the root. |
| Content-Type | request | application/json, or multipart/form-data on upload endpoints. |
| X-Image-Provider | request | Applies only to /v1/images/generations: picks the image provider when more than one is enabled. It has no effect on /v1/images/edits. Optional — there is a server default. |
Streaming
With "stream": true the response becomes text/event-stream: data: … lines, one per event, with no intermediate buffering. The last useful event carries the call's usage; after it comes data: [DONE].
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Ent"}}]}
data: {"id":"...","choices":[],"usage":{"prompt_tokens":812,"completion_tokens":214,"total_tokens":1026}}
data: [DONE]The gateway always asks the provider for the usage block, so the final usage chunk arrives whether or not you request it. If a stream ends without [DONE], treat the answer as truncated and retry — the connection was cut mid-flight.
What every response carries
On all three conversation routes the response is normalised before it reaches you. Three guarantees that hold on any of them, streaming or not:
modelechoes the name you asked for. It holds at the root of the body, inmessage.modelon the first event of the Anthropic shape, and inresponse.modelon every event of the Responses shape. What you send and what comes back speak the same language.- Reasoning arrives in
reasoning. It shows up underchoices[].messageon a whole response and underchoices[].deltaon a stream. The older namereasoning_contentstays alongside it while clients migrate — readreasoning. - Provider-internal fields do not come through.
system_fingerprint, a third party's build id, is stripped.modelalready answers "who served this".
Normalisation never breaks a response: a body that doesn't parse, a chunk split mid-flight, or an error coming from the provider all pass through untouched. If a new field appears, it reaches you even when the gateway doesn't know it yet.
Errors
Errors raised by the gateway always take the same shape: an error object with type, message and, where it helps, code and param. Provider errors are passed through with their original status and body.
{
"error": {
"type": "invalid_request_error",
"message": "Campo 'model' e obrigatorio."
}
}| Status | type | When it happens |
|---|---|---|
| 400 | invalid_request_error | Empty body, invalid JSON, missing required field, or an envelope rejected by one of the gateway rules. |
| 401 | — | Missing header, malformed key, revoked key, or an environment that isn't allowed. |
| 402 | insufficient_credits | Balance below the operation's minimum. The body carries the balance, the shortfall and a top-up URL. |
| 4xx / 5xx | passed through | Provider error (rate limit, refused content, outage). Status and body arrive as they came. |
| 503 | upstream_unavailable | The provider dropped before the first byte and the gateway's retries were exhausted. On streaming calls it arrives as an SSE event. |
Gateway codes
These are the refusals decided before calling the provider. All return 400, cost zero credits and carry an actionable message — you can fix the request and retry without guessing.
| code | Description |
|---|---|
| gateway_invalid_size | size outside the model's envelope: format, pixel step, minimum or maximum area, edge or aspect ratio. |
| gateway_unsupported_background | Transparent background requested from a model that doesn't support it, with no configured alternative. |
| gateway_unknown_model | Model not enabled for the endpoint. The message lists the accepted ones. |
| gateway_invalid_duration | duration missing, not an integer, or outside the allowed range for video. |
| gateway_invalid_resolution | Video resolution outside the accepted list. |
| gateway_invalid_input | Text above the speech-synthesis character ceiling. Split it up. |
Image-in-chat codes
Refusals specific to an image inside the conversation. These are also 400s decided before the provider, and the message always names the content block that caused it.
| code | Description |
|---|---|
| gateway_vision_unsupported | The requested model does not declare image support on this route. This is fail-closed on purpose: a new model that hasn't declared vision refuses rather than sending the image to a text-only destination. |
| gateway_image_invalid | The image block could not be read: corrupt base64, malformed data URI, or content that isn't an image. |
| gateway_image_mime_unsupported | Image type outside the accepted set. Use PNG, JPEG or WebP. |
| gateway_image_too_large | One of the images is above the per-file ceiling. |
| gateway_too_many_images | Too many images in one turn. Split the call, or send only the ones that matter. |
| gateway_payload_too_large | The body as a whole is above the request ceiling, even with each image inside its individual limit. |
| gateway_remote_image_blocked | The https:// URL could not be fetched: internal address, refused host, or a download that didn't complete. When in doubt, send the image as a base64 data URI. |
402 body
{
"error": {
"type": "insufficient_credits",
"message": "Saldo insuficiente pra chamar a API.",
"balance": 3,
"required": 10,
"missing": 7,
"plan_id": "starter",
"renews_at": "2026-09-01T00:00:00Z",
"upgrade_url": "https://account.entelecy.ai/plans"
}
}This is the body for the conversation, image, audio and video routes. /v1/search returns a short version with only message and upgrade_url — read the balance fields defensively, not as guaranteed.
Endpoints
Fourteen routes, grouped by capability. The path links to the section that details it; the right-hand column previews the billing unit — the detail is under Billing.
| Endpoint | Description | Billed unit |
|---|---|---|
| POST /v1/chat/completions | Conversation with text and images, in the Chat Completions shape. | tokens |
| POST /v1/messages | Same conversation, in the Anthropic Messages shape — for clients that already speak it. | tokens |
| POST /v1/responses | The same conversation in the Responses shape — this is what Codex speaks. | tokens |
| GET /v1/models | Catalogue of the public names and the routes each one is valid on. | not billed |
| POST /v1/images/generations | Text-to-image generation. | tokens |
| POST /v1/images/edits | Image editing with an optional mask. | tokens |
| POST /v1/audio/transcriptions | Audio transcription (speech → text). | seconds of audio |
| POST /v1/audio/speech | Speech synthesis (text → speech). | characters |
| GET /v1/audio/voices | Catalog of available voices. | not billed |
| POST /v1/videos/generations | Video generation (submit). | generated seconds |
| GET /v1/videos/generations/{id} | Poll a video in progress. | not billed |
| POST /v1/videos/uploads | Upload a reference media for image-to-video. | not billed |
| POST /v1/search | Web search with structured results. | per call |
| GET / | Service status. Anonymous. | not billed |
Conversation
The same conversation in three protocols: /v1/chat/completions (OpenAI shape), /v1/messages (Anthropic shape) and /v1/responses (Responses shape, which Codex speaks). The model names are the same across all three — the name is the quality tier, and it doesn't change meaning with the protocol. Pick the one your client already speaks; the first is the general-purpose one.
POST/v1/chat/completionssupports streaming (SSE)Fields the gateway reads or handles. Everything else is forwarded untouched.
| Field | Type | Description |
|---|---|---|
| modelrequired | string | Public model name: loom-flash or loom-pro. |
| effort | string | How much the model should think: none, high or max. |
| messagesrequired | array | Conversation turns, with role (system, user, assistant, tool) and content. |
| stream | boolean | true turns the response into an SSE stream. Defaults to false. |
| max_tokens | integer | Ceiling on generated tokens in the response. Omitted, the model's own ceiling applies. |
| temperature | number | Sampling randomness: low values make the answer more predictable, high values more varied. |
| tools | array | Up to 128 tools of type function, each with name (letters, digits, _ and -, up to 64 characters), description and parameters as a JSON Schema. See the shape below. |
| tool_choice | string / object | The choice policy: none, auto, required, or an object naming the tool to force. See below. |
| response_format | object | text, the default, or json_object. When asking for JSON, say so in the prompt too — without it the model may emit whitespace until it hits the token ceiling. |
| thinking | object | The protocol's own thinking axis, forwarded as sent. Use it to steer by hand; for the gateway's shortcut, use effort. |
| reasoning_effort | string | Likewise forwarded as sent. The provider's rule is high or max; to switch thinking off, use thinking with { "type": "disabled" }. |
Tools
The shape of tools, and the four ways to steer the choice with tool_choice:
"tools": [
{
"type": "function",
"function": {
"name": "buscar_pedido",
"description": "Busca um pedido pelo numero.",
"parameters": {
"type": "object",
"properties": { "numero": { "type": "string" } },
"required": ["numero"]
}
}
}
]
// tool_choice — as quatro formas
"tool_choice": "none" // responde sem chamar ferramenta
"tool_choice": "auto" // o modelo decide
"tool_choice": "required" // obriga a chamar alguma
"tool_choice": { "type": "function", "function": { "name": "buscar_pedido" } }Model and effort
Two independent axes, both in the body: model picks the tier and effort picks how much the model thinks. Neither implies the other — loom-flash with max and loom-pro with none are both valid, and both useful.
| model | Profile | When to use it |
|---|---|---|
| loom-flash | Fast and cheap, for volume | Short answers, classification, autocomplete, extraction — where latency rules. |
| loom-pro | More capable, for hard work | Code, multi-step analysis, long-form writing, agents with tools. |
| effort | Profile | When to use it |
|---|---|---|
| none | No explicit reasoning | Fastest and cheapest. Answers directly, with no thinking step. |
| high | Reasoning on | The recommended default when the answer has to be right, not just quick. |
| max | Reasoning at the ceiling | Hard problems: large refactors, long planning, correctness over cost. |
Without effort in the body, thinking and reasoning_effort travel exactly as you sent them — anyone already steering thinking by hand keeps working as before. And the response echoes back in model the same name you asked for: what you send and what comes back speak the same language.
The response, field by field
Chat Completions format. The gateway normalizes the vendor response before returning it: model comes back with the public name you asked for, and vendor-internal fields do not pass through. Fields the vendor adds that the gateway does not yet know are forwarded — what is documented here is what is stable.
| Field | Type | Description |
|---|---|---|
| id | string | Unique identifier for this call. Opaque: derive nothing from its shape. |
| object | string | chat.completion for a full response; chat.completion.chunk for each stream event. |
| created | integer | Creation time, in Unix seconds (UTC). |
| model | string | The same name you asked for (loom-flash). |
| choices | array | List of answers. Without n, exactly one. |
| choices[].index | integer | Position of this choice in the list. |
| choices[].message / delta | object | The generated message: role (assistant) and content. In a stream this field is called delta and carries the new piece, not the whole text. |
| ….reasoning | string | The reasoning, when the model thinks before answering. It is diagnostic text, not the answer: do not render it in place of content, and do not rely on its language or format. |
| ….reasoning_content | string | Old name for the same content, kept in parallel while clients migrate. It will go away; use reasoning. |
| choices[].finish_reason | string | Why it stopped: stop (natural end), length (hit the token ceiling), tool_calls (wants to call a tool). |
| usage | object | Usage for the call — this is what billing reads. In a stream it arrives in the last event, with an empty choices. |
| usage.prompt_tokens | integer | Input tokens (what you sent). |
| usage.completion_tokens | integer | Output tokens (what the model generated), reasoning included. |
| usage.total_tokens | integer | The sum. This is the number billing uses. |
| …details.cached_tokens | integer | Full path: usage.prompt_tokens_details.cached_tokens. The part of the input that hit the prompt cache — repeating a large prefix is far cheaper than resending it. |
| …details.reasoning_tokens | integer | Full path: usage.completion_tokens_details.reasoning_tokens. How much of the output was reasoning. Already counted inside completion_tokens — do not add it. |
The usage block is the billing basis: input, output, and the share of input that hit the prompt cache — replaying a large prefix is far cheaper than resending it cold.
Sending an image
An image travels inside the conversation, as one more block of content next to the text — there is no separate endpoint and no extra field. Send several blocks to send several images.
IMG=$(base64 -w0 tela.png)
curl https://api.entelecy.ai/v1/chat/completions \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-flash",
"messages": [
{ "role": "user", "content": [
{ "type": "text", "text": "O que ha de errado neste layout?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,'"$IMG"'" } }
]}
]
}'{ "type": "image_url", "image_url": { "url": "https://exemplo.com/tela.png", "detail": "high" } }The url takes either a base64 data URI, as above, or a public https:// address. detail (low, high, auto) controls how finely the image is read.
The same block works on /v1/messages, in the Anthropic shape: {"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}}. Images are billed as part of the turn, not as a separate unit.
Request
curl https://api.entelecy.ai/v1/chat/completions \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-flash",
"effort": "high",
"messages": [
{ "role": "system", "content": "Responda em pt-BR, direto ao ponto." },
{ "role": "user", "content": "Explique entelequia em tres linhas." }
]
}'const res = await fetch('https://api.entelecy.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-flash',
effort: 'high',
messages: [
{ role: 'system', content: 'Responda em pt-BR, direto ao ponto.' },
{ role: 'user', content: 'Explique entelequia em tres linhas.' },
],
}),
});
const data = await res.json();
console.log(data.choices[0].message.content);import os, requests
res = requests.post(
"https://api.entelecy.ai/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"model": "loom-flash",
"effort": "high",
"messages": [
{"role": "system", "content": "Responda em pt-BR, direto ao ponto."},
{"role": "user", "content": "Explique entelequia em tres linhas."},
],
},
timeout=(10, 600),
)
res.raise_for_status()
print(res.json()["choices"][0]["message"]["content"])Response200
{
"id": "bf1b8201-5085-455c-9556-8ebd39a0a34e",
"object": "chat.completion",
"created": 1786061432,
"model": "loom-flash",
"choices": [
{ "index": 0, "finish_reason": "stop",
"message": { "role": "assistant", "content": "Entelequia e…" } }
],
"usage": {
"prompt_tokens": 812,
"completion_tokens": 214,
"total_tokens": 1026,
"prompt_tokens_details": { "cached_tokens": 640 },
"completion_tokens_details": { "reasoning_tokens": 159 }
}
}POST/v1/messagessupports streaming (SSE)The Anthropic Messages shape, the same one Claude Code and other clients already speak. Underneath it is the same conversation and the same model as /v1/chat/completions — what changes is the wire contract: system moves out of messages into its own field, and max_tokens becomes required.
| Field | Type | Description |
|---|---|---|
| modelrequired | string | Public model name: loom-flash or loom-pro. |
| messagesrequired | array | Conversation turns, with role (user or assistant) and content — text or blocks. The system instruction goes in the system field, alongside. |
| max_tokensrequired | integer | Ceiling on generated tokens. Always send it: the Messages shape asks for this field on every call. |
| system | string / array | System instruction, as a root-level field — this is where it goes in this shape. |
| stream | boolean | true turns the response into an SSE stream, in Anthropic's event shape. Defaults to false. |
The response
The Messages shape. The text arrives in content, which is a list of blocks — not a string, as in Chat Completions:
| Field | Type | Description |
|---|---|---|
| id | string | Identifier for this message. |
| type | string | message. |
| role | string | assistant. |
| model | string | The same name you asked for. |
| content | array | List of blocks. Each block carries type (text) and text — the answer is those joined together. |
| stop_reason | string | Why it stopped. end_turn is the natural end. |
| stop_sequence | string | The sequence that interrupted generation, when one of them stopped it. |
| usage | object | Usage for the call, with input_tokens and output_tokens. |
Errors on this route come in the Anthropic envelope: {"type":"error","error":{"type":…,"message":…}}, with one extra type on the outside. On the other routes the body starts at error directly. If your error handling is shared across routes, read both shapes.
Request
curl https://api.entelecy.ai/v1/messages \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-flash",
"max_tokens": 512,
"system": "Responda em pt-BR, direto ao ponto.",
"messages": [
{ "role": "user", "content": "Explique entelequia em tres linhas." }
]
}'const res = await fetch('https://api.entelecy.ai/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-flash',
max_tokens: 512,
system: 'Responda em pt-BR, direto ao ponto.',
messages: [
{ role: 'user', content: 'Explique entelequia em tres linhas.' },
],
}),
});
const data = await res.json();
console.log(data.content[0].text);import os, requests
res = requests.post(
"https://api.entelecy.ai/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}",
"anthropic-version": "2023-06-01",
},
json={
"model": "loom-flash",
"max_tokens": 512,
"system": "Responda em pt-BR, direto ao ponto.",
"messages": [
{"role": "user", "content": "Explique entelequia em tres linhas."},
],
},
timeout=(10, 600),
)
res.raise_for_status()
print(res.json()["content"][0]["text"])POST/v1/responsessupports streaming (SSE)The Responses shape. It is what Codex speaks: it no longer talks Chat Completions to an external provider, and requires wire_api = "responses". The body follows the standard Responses API.
Use loom-flash on this route — it is the name this one serves. On the other two conversation routes, loom-pro is available as well.
| Field | Type | Description |
|---|---|---|
| modelrequired | string | loom-flash. |
| input | string / array | What to send the model: plain text or the conversation's item list. Send input, instructions, or both. |
| instructions | string | System instruction, as a root-level field. |
| stream | boolean | true turns the response into an SSE event stream. Defaults to false. |
| reasoning | object | The Responses shape's thinking axis — this is where this protocol's effort goes. |
| max_output_tokens | integer | Ceiling on generated tokens in the response. |
| tools | array | Tools available to the model, of types function and web_search. |
The response
The body arrives inside a response envelope — the shape difference that most catches people coming from Chat Completions:
| Field | Type | Description |
|---|---|---|
| response | object | Envelope wrapping the whole response. model, usage and the output live inside it. |
| response.model | string | The same name you asked for, nested in the envelope. |
| response.output_text | string | The generated text. |
| response.usage | object | Usage for the call, with input_tokens, output_tokens, and the cached share in input_tokens_details.cached_tokens. |
Each call stands alone: store always returns false and previous_response_id always returns null. To chain turns, send the history in input.
End of stream
With stream: true, the flow ends on a terminal event — not on [DONE]. Handle all three:
response.completed— finished normally, and this is the event whereusagearrives complete.response.incomplete— stopped early, for instance on hitting the token ceiling.response.failed— failed, with the detail inerror.
Three differences from /v1/chat/completions that show up in your code:
- Usage carries different names:
input_tokensandoutput_tokens, with the cached share insideinput_tokens_details. - The stream does not end with
[DONE]: it ends on a terminal event —response.completed,response.incompleteorresponse.failed. Usage arrives oncompleted, without asking for it. - The response's
modelis nested underresponse.model, both in the whole body and in each stream event. The name that comes back is still the public one you asked for.
Request
curl https://api.entelecy.ai/v1/responses \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-flash",
"input": "Explique entelequia em tres linhas."
}'const res = await fetch('https://api.entelecy.ai/v1/responses', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-flash',
input: 'Explique entelequia em tres linhas.',
}),
});
const data = await res.json();
// o usage aqui e input_tokens / output_tokens, nao prompt_tokens
console.log(data.usage.input_tokens, data.usage.output_tokens);import os, requests
res = requests.post(
"https://api.entelecy.ai/v1/responses",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"model": "loom-flash",
"input": "Explique entelequia em tres linhas.",
},
timeout=(10, 600),
)
res.raise_for_status()
# o model da resposta vive aninhado em response.model
print(res.json())Response200
{
"usage": {
"input_tokens": 812,
"input_tokens_details": { "cached_tokens": 640 },
"output_tokens": 214
}
}effort only applies to /v1/chat/completions. On the other two routes it has no effect. To control thinking there, use the protocol's own axis: thinking in the Anthropic shape, reasoning.effort in Responses.
Image
Image generation and editing in the standard shape, with envelope validation at the gateway. The response carries the image as base64 plus the call's token usage.
POST/v1/images/generations| Field | Type | Description |
|---|---|---|
| modelrequired | string | Public name of the image model: loom-image. See Models. |
| promptrequired | string | What to generate. Specific prompts beat stacked adjectives. |
| size | string | WIDTHxHEIGHT in pixels, or auto. There is no fixed list: anything that passes the envelope rules below is valid. Valid examples: 1024x1024, 1536x1024, 1024x1536, 1920x1088, 3840x2160. |
| quality | string | low, medium, high or auto. Higher quality spends more output tokens, and billing follows. |
| background | string | auto, opaque or transparent. |
| n | integer | Number of images per call, from 1 to 10. Each one is generated and billed separately. |
| output_format | string | png, jpeg or webp. Omitted, it returns png. jpeg is faster — worth the swap when latency matters. |
| output_compression | integer | Compression level, from 0 to 100. Applies to jpeg and webp. |
Envelope validated at the gateway
There is no fixed list of sizes. Anything that satisfies the four rules below is valid, and they are checked before the call goes out — an envelope error comes back in milliseconds and costs zero. Omitting size, or sending auto, skips the check and lets the default decide.
- Both sides must be multiples of 16.
- Total area sits between 655,360 and 8,294,400 pixels, and the longest edge does not exceed 3840.
- The ratio between the sides does not exceed 3:1.
- A transparent background asked of a model without support is rerouted to the alternate model — and billing follows the model actually used.
The response
The image comes back as base64 in the body, not as a URL to fetch later:
| Field | Type | Description |
|---|---|---|
| created | integer | Creation moment, in Unix seconds. |
| data | array | List with the generated images. Each item carries b64_json, the image in base64 — decode it and write it out. |
| usage | object | Usage for the call: input_tokens (the prompt), output_tokens (the image) and total_tokens. This is what billing reads. |
| …cached_tokens | integer | Under input_tokens_details.cached_tokens, the share of input that hit the cache. |
For 16:9, use 1920x1088 or 3840x2160 — at Full HD, 1088 stands in for 1080, which is not a multiple of 16. For a square, start at 1024x1024: it is the smallest multiple of 16 that clears the minimum area. The 400 message always names the rule that caught it, and the number behind it.
Request
curl https://api.entelecy.ai/v1/images/generations \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-image",
"prompt": "Fachada de uma padaria de bairro ao amanhecer, luz quente, fotografia",
"size": "1536x1024",
"quality": "high",
"n": 1
}'import { writeFile } from 'node:fs/promises';
const res = await fetch('https://api.entelecy.ai/v1/images/generations', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-image',
prompt: 'Fachada de uma padaria de bairro ao amanhecer, luz quente, fotografia',
size: '1536x1024',
quality: 'high',
n: 1,
}),
});
const body = await res.json();
await writeFile('saida.png', Buffer.from(body.data[0].b64_json, 'base64'));import base64, os, requests
res = requests.post(
"https://api.entelecy.ai/v1/images/generations",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"model": "loom-image",
"prompt": "Fachada de uma padaria de bairro ao amanhecer, luz quente, fotografia",
"size": "1536x1024",
"quality": "high",
"n": 1,
},
timeout=(10, 300),
)
res.raise_for_status()
with open("saida.png", "wb") as f:
f.write(base64.b64decode(res.json()["data"][0]["b64_json"]))Response200
{
"created": 1785969142,
"data": [{ "b64_json": "iVBORw0KGgoAAAANSUhEUg…" }],
"usage": {
"input_tokens": 42,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens": 1568,
"total_tokens": 1610
}
}POST/v1/images/editsEditing takes JSON: the base image (one or more) and an optional mask, both base64. The gateway converts it to the multipart shape the provider expects, so you don't have to build the upload.
| Field | Type | Description |
|---|---|---|
| modelrequired | string | Public name of the image model: loom-image. See Models. |
| promptrequired | string | What to change in the image you sent. Describe the edit, not the whole scene. |
| imagerequired | array | Base image in base64. More than one is accepted; the mask goes with the first. |
| mask | string | Optional mask in base64. The transparent area is the one that may change. |
| size | string | WIDTHxHEIGHT in pixels, or auto. There is no fixed list: anything that passes the envelope rules below is valid. Valid examples: 1024x1024, 1536x1024, 1024x1536, 1920x1088, 3840x2160. |
The mask marks what may change: the transparent area is the editable one. Send mask and base image at the same dimensions.
Request
{
"model": "loom-image",
"prompt": "Troque o fundo por um ceu limpo no fim da tarde",
"image": ["iVBORw0KGgo…"],
"mask": "iVBORw0KGgo…",
"size": "1024x1024"
}import { readFile } from 'node:fs/promises';
const b64 = async p => (await readFile(p)).toString('base64');
const res = await fetch('https://api.entelecy.ai/v1/images/edits', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-image',
prompt: 'Troque o fundo por um ceu limpo no fim da tarde',
image: [await b64('base.png')],
mask: await b64('mascara.png'), // area transparente = o que pode mudar
size: '1024x1024',
}),
});import base64, os, requests
def b64(caminho):
with open(caminho, "rb") as f:
return base64.b64encode(f.read()).decode()
res = requests.post(
"https://api.entelecy.ai/v1/images/edits",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"model": "loom-image",
"prompt": "Troque o fundo por um ceu limpo no fim da tarde",
"image": [b64("base.png")],
"mask": b64("mascara.png"), # area transparente = o que pode mudar
"size": "1024x1024",
},
timeout=(10, 300),
)
res.raise_for_status()Audio
Two paths: transcribing speech into text and synthesizing text into speech. The transcription engine is selected by the model field; without it, the server default applies.
POST/v1/audio/transcriptionsmultipart/form-data| Field | Type | Description |
|---|---|---|
| filerequired | file | Audio file: m4a, mp3, wav, ogg or webm. |
| model | string | loom-audio. You can leave it out — this route already knows it is speech-to-text. Same name as the voice: the endpoint decides the direction. |
| language | string | ISO-639 code of the spoken language: pt, en, es. Omitted, the language is detected from the audio. |
The response is plain text (text/plain), not JSON — that's the contract Entelecy clients already consume. The audio duration measured during transcription is the billing basis.
Request
curl https://api.entelecy.ai/v1/audio/transcriptions \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-F "[email protected]" \
-F "model=loom-audio" \
-F "language=pt"import { openAsBlob } from 'node:fs';
const form = new FormData();
form.set('file', await openAsBlob('reuniao.m4a'), 'reuniao.m4a');
form.set('model', 'loom-audio');
form.set('language', 'pt');
const res = await fetch('https://api.entelecy.ai/v1/audio/transcriptions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}` },
body: form,
});
// a resposta e texto puro, nao JSON
console.log(await res.text());import os, requests
with open("reuniao.m4a", "rb") as f:
res = requests.post(
"https://api.entelecy.ai/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
files={"file": ("reuniao.m4a", f, "audio/m4a")},
data={"model": "loom-audio", "language": "pt"},
timeout=(10, 300),
)
res.raise_for_status()
print(res.text) # texto puro, nao JSONResponse200 · text/plain
Bom dia. Comecando a reuniao de quinta…POST/v1/audio/speechreturns audio bytes| Field | Type | Description |
|---|---|---|
| inputrequired | string | Text to speak, up to 5,000 characters per call. For longer texts, split it and join the audio — one call per part. |
| voice | string | Voice identifier, taken from /v1/audio/voices. Omitted, it uses the account's default voice. |
| model | string | loom-audio. You can leave it out — the route already knows which engine to use. See Models. |
| response_format | string | mp3, a shortcut for the default, or a format in the codec_rate_bitrate shape. Omitted, it returns mp3_44100_128. |
| voice_settings | object | Fine voice tuning. Fields and defaults: stability (0.5) — lower means broader emotional range; similarity_boost (0.75) — how closely it holds to the original voice; style (0) — style exaggeration; use_speaker_boost (true); speed (1.0) — from 0.7 to 1.2, below 1 is slower, above 1 is faster. |
Audio formats
The format name follows codec_rate_bitrate — mp3_44100_128 is MP3 at 44.1 kHz and 128 kbps. The available codecs:
mp3_22050_32,mp3_44100_32,mp3_44100_64,mp3_44100_96,mp3_44100_128,mp3_44100_192opus_48000_32,opus_48000_64,opus_48000_96,opus_48000_128,opus_48000_192pcm_8000,pcm_16000,pcm_22050,pcm_24000,pcm_32000,pcm_44100,pcm_48000wav_8000,wav_16000,wav_22050,wav_24000,wav_32000,wav_44100,wav_48000ulaw_8000andalaw_8000— the telephony formats, used by platforms such as Twilio.
Request
curl https://api.entelecy.ai/v1/audio/speech \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "loom-audio",
"input": "A entrega de quinta esta confirmada. Qualquer mudanca, aviso por aqui.",
"response_format": "mp3_44100_128"
}' \
--output aviso.mp3import { writeFile } from 'node:fs/promises';
const res = await fetch('https://api.entelecy.ai/v1/audio/speech', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'loom-audio',
input: 'A entrega de quinta esta confirmada. Qualquer mudanca, aviso por aqui.',
response_format: 'mp3_44100_128',
}),
});
// a resposta e o audio em bytes, nao JSON
await writeFile('aviso.mp3', Buffer.from(await res.arrayBuffer()));import os, requests
res = requests.post(
"https://api.entelecy.ai/v1/audio/speech",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"model": "loom-audio",
"input": "A entrega de quinta esta confirmada. Qualquer mudanca, aviso por aqui.",
"response_format": "mp3_44100_128",
},
timeout=(10, 300),
)
res.raise_for_status()
with open("aviso.mp3", "wb") as f:
f.write(res.content) # bytes de audio, nao JSONGET/v1/audio/voicesnot billedLists the voices available on the account, including the provider's premade ones. Requires authentication, is never billed, and is the correct source for the identifiers used in voice.
The catalogue is forwarded exactly as the provider returns it — the gateway does not rewrite the body. That is why this page pins no response shape here: read the list from the response and use the identifier it carries. It's the same reason the voice field has no closed list in the table above.
Request
curl https://api.entelecy.ai/v1/audio/voices \
-H "Authorization: Bearer $ENTELECY_API_KEY"const res = await fetch('https://api.entelecy.ai/v1/audio/voices', {
headers: { 'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}` },
});
const catalogo = await res.json();import os, requests
res = requests.get(
"https://api.entelecy.ai/v1/audio/voices",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
timeout=(10, 60),
)
res.raise_for_status()
catalogo = res.json()Video
Video generation is asynchronous: you submit the request, get an identifier and poll until the state is terminal. Image-to-video uses a reference media uploaded beforehand.
POST/v1/videos/generationsasynchronous (submit + poll)| Field | Type | Description |
|---|---|---|
| model | string | Public name of the video model: loom-video. Optional — leave it out and the mode is picked from the shape of the request (see Modes, below). |
| promptrequired | string | The scene, the camera movement and the pacing. |
| durationrequired | integer | Duration in seconds, from 4 to 15. Always send a number: it is what sets the billing. |
| resolution | string | 480p or 720p. Omitted, the model's default applies. |
| ratio | string | Frame ratio: 16:9, 9:16, 4:3, 3:4, 1:1, 21:9 or adaptive. Omitted, adaptive applies — the model picks from the content. |
| generate_audio | boolean | true generates a soundtrack with the video, false returns it silent. Omitted, it comes with audio — the model's default is true. |
| bitrate_mode | string | standard or high. Omitted, standard applies. |
| watermark | boolean | true watermarks the video. Omitted, false applies. |
| return_last_frame | boolean | true also returns the last frame, handy for stitching one clip into the next. Omitted, false applies. |
| image_url | string | URL of an image to animate. The presence of this field is what switches on image-to-video. |
| image_urls | array | URLs of reference images — a character, an object or a style to hold across the scene. |
| video_urls | array | URLs of reference videos, for movement or continuity. |
| audio_urls | array | URLs of reference audio, when the generation should follow a track. |
Modes
One route serves three paths. What picks between them is the shape of the body, not a mode field — which is why model can be left out:
| Mode | What triggers it | Description |
|---|---|---|
| text → video | none of the others | Just prompt. The scene is born entirely from the description. |
| image → video | image_url | Animates an image that already exists. The URL comes from /v1/videos/uploads or from a public address. |
| reference → video | image_urls video_urls audio_urls | Generates while holding on to elements from media you supply — character, style, movement or track. |
Reference wins over plain image: if the body carries image_urls, video_urls or audio_urls, the request goes down that path even when a loose image_url is also present. Sending both does not combine the modes — pick one.
Billing is armed on submit and fired by the first poll that sees the video ready. Polling repeatedly does not double-charge; if the job fails, there is no debit.
Request
# 1) submit — devolve o id da predicao
curl https://api.entelecy.ai/v1/videos/generations \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Plano aereo de uma feira livre ao amanhecer, camera avancando devagar",
"duration": 6,
"resolution": "720p"
}'
# 2) poll — ate status completed
curl https://api.entelecy.ai/v1/videos/generations/pred_01J8Z… \
-H "Authorization: Bearer $ENTELECY_API_KEY"const KEY = process.env.ENTELECY_API_KEY;
const BASE = 'https://api.entelecy.ai';
const sub = await fetch(`${BASE}/v1/videos/generations`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },
// sem `model`: o modo sai da forma do corpo — so prompt = texto para video
body: JSON.stringify({
prompt: 'Plano aereo de uma feira livre ao amanhecer, camera avancando devagar',
duration: 6,
resolution: '720p',
}),
});
const { data: { id } } = await sub.json();
// poll ate um estado terminal
let job;
do {
await new Promise(r => setTimeout(r, 5000));
const res = await fetch(`${BASE}/v1/videos/generations/${id}`, {
headers: { 'Authorization': `Bearer ${KEY}` },
});
({ data: job } = await res.json());
} while (job.status === 'queued' || job.status === 'processing');
console.log(job.outputs?.[0]?.url);import os, time, requests
KEY = os.environ["ENTELECY_API_KEY"]
BASE = "https://api.entelecy.ai"
H = {"Authorization": f"Bearer {KEY}"}
# sem 'model': o modo sai da forma do corpo — so prompt = texto para video
sub = requests.post(
f"{BASE}/v1/videos/generations",
headers=H,
json={
"prompt": "Plano aereo de uma feira livre ao amanhecer, camera avancando devagar",
"duration": 6,
"resolution": "720p",
},
timeout=(10, 120),
)
sub.raise_for_status()
pred = sub.json()["data"]["id"]
while True:
time.sleep(5)
job = requests.get(f"{BASE}/v1/videos/generations/{pred}", headers=H, timeout=(10, 60))
job.raise_for_status()
estado = job.json()["data"]
if estado["status"] not in ("queued", "processing"):
break
print(estado.get("outputs", [{}])[0].get("url"))Response200
// 1) submit
{ "data": { "id": "pred_01J8Z…", "status": "queued" } }
// 2) poll, ja pronto
{ "data": { "status": "completed", "outputs": [{ "url": "https://…/video.mp4" }] } }GET/v1/videos/generations/{id}not billedThe submit returns an identifier; this route says where it stands. Poll until it reaches a terminal state — the poll itself is never billed.
| Field | Type | Description |
|---|---|---|
| data.id | string | Identifier for the generation, the same one that goes in the poll path. It arrives in data.id. |
| data.status | string | Current state. Done is completed or succeeded — treat both as a successful end. |
| data.outputs | array | List with the generated media; the video URL is in outputs[].url. |
It is the first poll that sees a terminal state that fires the charge for the seconds requested on submit. Polling again afterwards does not charge twice.
Response200
{
"data": {
"id": "pred_01J8Z…",
"status": "completed",
"outputs": [{ "url": "https://…/video.mp4" }]
}
}POST/v1/videos/uploadsmultipart/form-dataUploads the reference media and returns the URL that goes in image_url (or in the reference fields) of the generation body. It is multipart/form-data with a single field, and is never billed.
| Field | Type | Description |
|---|---|---|
| filerequired | file | The media to upload. It is the form's only field. |
Request
curl https://api.entelecy.ai/v1/videos/uploads \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-F "[email protected]"
# a URL devolvida aqui e a que vai em image_url no submit da geracaoimport { openAsBlob } from 'node:fs';
const form = new FormData();
form.set('file', await openAsBlob('referencia.png'), 'referencia.png');
const res = await fetch('https://api.entelecy.ai/v1/videos/uploads', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}` },
body: form,
});
// esta URL vai em image_url no submit da geracao
const { url } = await res.json();import os, requests
with open("referencia.png", "rb") as f:
res = requests.post(
"https://api.entelecy.ai/v1/videos/uploads",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
files={"file": ("referencia.png", f, "image/png")},
timeout=(10, 120),
)
res.raise_for_status()
# esta URL vai em image_url no submit da geracao
url = res.json()Search
Web search with structured results — built to give an agent current context before it answers. The body is forwarded to the engine, so region, language and count parameters behave as they do upstream.
POST/v1/search| Field | Type | Description |
|---|---|---|
| qrequired | string | The query. It is the one field the search always asks for. |
| gl | string | Country for the results, as a two-letter code: br, us, pt. Changes what counts as local. |
| hl | string | Language of the results, as a two-letter code: pt, en, es. |
| num | integer | Number of results. Omitted, 10 come back. |
| page | integer | Result page, to paginate beyond the first. |
The response arrives as the engine returned it: a direct answer block where one exists, organic results, knowledge panels and so on. Billing is per successful call, regardless of how many results come back.
Request
curl https://api.entelecy.ai/v1/search \
-H "Authorization: Bearer $ENTELECY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "relatorio anual industria de embalagens brasil",
"gl": "br",
"hl": "pt",
"num": 10
}'const res = await fetch('https://api.entelecy.ai/v1/search', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: 'relatorio anual industria de embalagens brasil',
gl: 'br',
hl: 'pt',
num: 10,
}),
});
const { organic } = await res.json();import os, requests
res = requests.post(
"https://api.entelecy.ai/v1/search",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
json={
"q": "relatorio anual industria de embalagens brasil",
"gl": "br",
"hl": "pt",
"num": 10,
},
timeout=(10, 60),
)
res.raise_for_status()
resultados = res.json()Models
The model field takes an Entelecy public name. It describes the capability you want, and is valid on the routes listed next to it:
| model | Endpoint | Description |
|---|---|---|
| loom-flash | /v1/chat/completions /v1/messages /v1/responses | Text and images, fast and cheap for volume. Accepts effort. |
| loom-pro | /v1/chat/completions /v1/messages | Text and images, higher capability for hard work. Accepts effort. |
| loom-image | /v1/images/generations /v1/images/edits | Image generation. Transparent background is handled by the gateway — you do not switch names. |
| loom-audio | /v1/audio/speech | Speech synthesis with natural Portuguese prosody. |
| loom-audio | /v1/audio/transcriptions | Audio transcription. Same name as the voice: the route decides the direction. |
| loom-video | /v1/videos/generations | Short video generation, from text or from a reference image. |
GET/v1/modelsnot billedThe text and image names from the table above, served by the API: if a name shows up here, it works on the routes listed beside it. The catalog does not list loom-audio or loom-video — they work on the audio and video routes all the same, but do not go through this registry. Requires authentication and is never billed.
This is the route Codex uses to validate the provider: it calls GET {base_url}/models and only accepts 2xx, 401 or 403 before attempting any turn.
Request
curl https://api.entelecy.ai/v1/models \
-H "Authorization: Bearer $ENTELECY_API_KEY"const res = await fetch('https://api.entelecy.ai/v1/models', {
headers: { 'Authorization': `Bearer ${process.env.ENTELECY_API_KEY}` },
});
const { data } = await res.json();
for (const m of data) console.log(m.id, m.endpoints.join(' '));import os, requests
res = requests.get(
"https://api.entelecy.ai/v1/models",
headers={"Authorization": f"Bearer {os.environ['ENTELECY_API_KEY']}"},
timeout=(10, 60),
)
res.raise_for_status()
for m in res.json()["data"]:
print(m["id"], m["endpoints"])Response200
{
"object": "list",
"data": [
{
"id": "loom-flash",
"object": "model",
"owned_by": "entelecy",
"endpoints": ["/v1/chat/completions", "/v1/messages", "/v1/responses"]
},
{
"id": "loom-image",
"object": "model",
"owned_by": "entelecy",
"endpoints": ["/v1/images/edits", "/v1/images/generations"]
}
]
}How we choose
We are not loyal to a vendor. Every segment is a moving field — a new model each month, prices falling, capabilities shifting — and what makes sense today may not next quarter. We track the relevant families in each, measure them on our own material, and switch when it pays.
The criteria, in order:
- Quality in Brazilian Portuguese. Measured on real customer content, not on a translated benchmark.
- Cost per accepted result. Not price per token: the price of the answer that survived review.
- Predictable latency. A good model that swings from 3 to 40 seconds is no good for an interactive product.
- Contract stability. A provider that changes shapes without notice is expensive over time.
Agents
Reasoning, code and tools
Chat, text generation and extraction, reading images and documents, and the agent loop with tools. This is where the gap between models shows most — and where the effort scale pays best.
Families tracked in this segment
Image
Generation and editing
Brand assets, photographic scenes, illustration, masked editing and transparent backgrounds. We weigh prompt adherence, legible typography inside the image, and consistency across variations.
Families tracked in this segment
Voice
Speech synthesis
Long narration, short interactive replies, expressive reading. The cut here is how natural it sounds in Brazilian Portuguese — most options still sound translated — plus latency and style control.
Families tracked in this segment
Transcription
Speech to text
Meetings, dictation, noisy field audio. Accuracy in Portuguese, per-word timing, speaker separation and cost per hour of audio all weigh in.
Families tracked in this segment
Video
Text to video and image to video
Short clips for content, animating an existing image, directed camera movement. Temporal coherence, prompt adherence and cost per second drive the choice.
Families tracked in this segment
Search
Web with structured results
Current context so an agent can answer without hallucinating: organic results, direct answers and panels, as clean JSON. Low latency matters more than volume — an agent searches several times per task.
Families tracked in this segment
This is the landscape we track per segment, not an availability catalog. What is active behind each public name is our curation and changes when something better shows up — without breaking your integration, because the name does not change with it.
Billing
Everything is billed in credits, on the wallet of the credential's owner. Each capability uses the unit that matches the real work:
| Endpoint | Billed unit | Where the measure comes from |
|---|---|---|
| /v1/chat/completions /v1/messages | tokens | The response's usage, with input, output and cache broken out. |
| /v1/responses | tokens | The response's usage, under the Responses names: input_tokens, output_tokens, and the cached share inside input_tokens_details. |
| /v1/images/* | tokens | The response's usage, including generated image tokens. |
| /v1/audio/transcriptions | seconds of audio | Audio duration measured during transcription, rounded up. |
| /v1/audio/speech | characters | Character count of the submitted text. |
| /v1/videos/generations | generated seconds | Duration requested on submit, billed when the video is ready. |
| /v1/search | per call | One unit per successful call. |
Before forwarding, the gateway checks a minimum balance for the operation — higher for image and video, which cost more. Without balance, the answer is 402 with the shortfall and a top-up link, and nothing is spent at the provider.
The debit happens after the response, on real usage, and is idempotent per call: a network retry doesn't charge twice. Calls that fail at the provider produce no debit.
If the balance runs out between the initial check and the debit, the response has already been delivered and the debit is recorded anyway. That's the only case where a wallet can go negative — the next call comes back 402.
Implementation examples
Paste-ready code, using the same models Loom — our own product — runs in production, with the handling that usually gets skipped: insufficient balance, a chunk split mid-stream, and reading usage at the end of the call.
Streaming chat
No dependencies: fetch and TextDecoder are native on Node 18+. The parser keeps the buffer remainder because a network chunk can cut an SSE line in half.
const BASE = 'https://api.entelecy.ai';
const KEY = process.env.ENTELECY_API_KEY;
/**
* Chat com streaming. Devolve o texto completo e o usage do ultimo chunk.
* O gateway sempre pede usage no fim do stream — nao e preciso configurar nada.
*/
export async function chatStream(messages, { effort = 'high', onDelta } = {}) {
const res = await fetch(`${BASE}/v1/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json',
},
// model e effort sao eixos separados: o nome escolhe a faixa, o effort a profundidade
body: JSON.stringify({ model: 'loom-flash', effort, stream: true, messages }),
});
if (!res.ok) {
// 402 = saldo insuficiente; o corpo traz balance/required/upgrade_url
const err = await res.json().catch(() => ({}));
throw new Error(`${res.status} ${err?.error?.type ?? 'erro'}: ${err?.error?.message ?? ''}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '', text = '', usage = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE: eventos separados por linha; so nos importam as linhas "data: "
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') continue;
let chunk;
try { chunk = JSON.parse(payload); } catch { continue; } // chunk partido
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) { text += delta; onDelta?.(delta); }
if (chunk.usage) usage = chunk.usage; // ultimo chunk
}
}
return { text, usage };
}Generate an image and save it
import { writeFile } from 'node:fs/promises';
export async function gerarImagem(prompt, { size = '1024x1024' } = {}) {
const res = await fetch(`${BASE}/v1/images/generations`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'loom-image', prompt, size, n: 1 }),
});
const body = await res.json();
if (!res.ok) {
// gateway_invalid_size chega aqui ANTES de custar credito
throw new Error(`${body.error?.code ?? res.status}: ${body.error?.message}`);
}
await writeFile('saida.png', Buffer.from(body.data[0].b64_json, 'base64'));
return body.usage;
}Streaming chat
With requests. Note the paired timeout: short to connect, long to read — reasoning generations can run past a minute.
import json, os, requests
BASE = "https://api.entelecy.ai"
KEY = os.environ["ENTELECY_API_KEY"]
def chat_stream(messages, effort: str = "high"):
"""Chat com streaming. Retorna (texto, usage)."""
with requests.post(
f"{BASE}/v1/chat/completions",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
json={"model": "loom-flash", "effort": effort, "stream": True, "messages": messages},
stream=True,
timeout=(10, 600), # conexao curta, leitura longa
) as res:
if res.status_code == 402:
raise RuntimeError(f"saldo insuficiente: {res.json()['error']}")
res.raise_for_status()
texto, usage = [], None
for raw in res.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data: "):
continue
payload = raw[6:].strip()
if payload == "[DONE]":
break
chunk = json.loads(payload)
if chunk.get("choices"):
delta = chunk["choices"][0].get("delta", {}).get("content")
if delta:
texto.append(delta)
print(delta, end="", flush=True)
if chunk.get("usage"):
usage = chunk["usage"]
return "".join(texto), usageTranscribe a file
def transcrever(caminho: str, language: str = "pt") -> str:
with open(caminho, "rb") as f:
res = requests.post(
f"{BASE}/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {KEY}"},
files={"file": (os.path.basename(caminho), f, "audio/m4a")},
data={"model": "loom-audio", "language": language},
timeout=(10, 300),
)
res.raise_for_status()
return res.text # o endpoint devolve texto puro, nao JSONStreaming chat
With HttpClient and System.Text.Json, reading the stream as it arrives instead of waiting for the whole body.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public sealed class EntelecyClient(HttpClient http, string apiKey)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Chat com streaming: entrega cada delta no callback e devolve o usage final.</summary>
public async Task<JsonElement?> ChatStreamAsync(
object[] messages, Action<string> onDelta, string effort = "high", CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new { model = "loom-flash", effort, stream = true, messages });
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions")
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
if (!res.IsSuccessStatusCode)
throw new InvalidOperationException(
$"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync(ct)}");
await using var stream = await res.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
JsonElement? usage = null;
while (await reader.ReadLineAsync(ct) is { } line)
{
if (!line.StartsWith("data: ", StringComparison.Ordinal)) continue;
var payload = line[6..].Trim();
if (payload == "[DONE]") break;
JsonDocument doc;
try { doc = JsonDocument.Parse(payload); } catch (JsonException) { continue; }
using (doc)
{
if (doc.RootElement.TryGetProperty("choices", out var choices)
&& choices.GetArrayLength() > 0
&& choices[0].TryGetProperty("delta", out var delta)
&& delta.TryGetProperty("content", out var content))
{
onDelta(content.GetString() ?? "");
}
if (doc.RootElement.TryGetProperty("usage", out var u))
usage = u.Clone();
}
}
return usage;
}
}Integrations
The API speaks the formats coding CLIs already use, so you can point your tool here without an adapter in between. Below is what has been tested.
Claude Code
It speaks the Anthropic format (/v1/messages), which the API exposes. Two variables point the CLI here; the other two pick the model, because the /model picker only lists its own native models.
export ANTHROPIC_BASE_URL=https://api.entelecy.ai
export ANTHROPIC_AUTH_TOKEN=$ENTELECY_API_KEY
# The main model and the background one come from variables:
# its /model picker only lists its own native models.
export ANTHROPIC_MODEL=loom-flash
export ANTHROPIC_DEFAULT_HAIKU_MODEL=loom-flash
claudeBefore opening the CLI, confirm the key and the address with a one-token request. A response starting with {"id":"msg_ proves both are right:
curl -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"loom-flash","max_tokens":1,"messages":[{"role":"user","content":"."}]}'OpenCode
Uses the @ai-sdk/openai-compatible package over /v1/chat/completions. Drop the block below into your opencode.json and export ENTELECY_API_KEY:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"entelecy": {
"npm": "@ai-sdk/openai-compatible",
"name": "Entelecy",
"options": {
"baseURL": "https://api.entelecy.ai/v1",
"apiKey": "{env:ENTELECY_API_KEY}"
},
"models": {
"loom-flash": { "name": "Loom Flash" }
}
}
}
}Codex
Speaks the Responses API (/v1/responses). The profile goes in a separate file so your everyday config.toml stays untouched — in Codex 0.142.0 the [profiles.x] table became legacy. Save it as ~/.codex/entelecy.config.toml:
# ~/.codex/entelecy.config.toml (Windows: C:\Users\<user>\.codex\...)
# A SEPARATE file: your everyday config.toml is left alone.
# ORDER MATTERS: in TOML, a key written after a [header] belongs to
# that table; that is why the root ones come first.
model = "loom-flash"
model_provider = "entelecy"
model_context_window = 128000
model_catalog_json = 'C:\Users\<user>\.codex\entelecy_models.json'
forced_login_method = "api"
[model_providers.entelecy]
name = "Entelecy"
base_url = "https://api.entelecy.ai/v1" # WITH /v1: Codex builds the routes from here
env_key = "ENTELECY_API_KEY"
wire_api = "responses" # required; the "chat" value was removed from Codex
supports_websockets = false # the gateway speaks HTTP/SSE, not WebSocket
stream_idle_timeout_ms = 360000 # a long turn streams for a while with no new byteDownload the model catalog to ~/.codex/entelecy_models.json (without it Codex doesn't know the context window or the reasoning levels), export the key and call the profile:
setx ENTELECY_API_KEY "kriou_live_..." # Windows; on bash: export ENTELECY_API_KEY=...
codex --profile entelecyLimits and good practice
- Stream long responses. Beyond perceived speed, it avoids proxy timeouts on slow generations.
- Retry with exponential backoff. Rate limits and provider outages are transient; gateway
4xxerrors don't improve on retry. - Cache the stable prefix. On prompts with a repeated knowledge base, it's the easiest saving available.
- Validate image size in your own form. The gateway refuses for free, but the round trip still costs the user time.
- Split long text before synthesis. There is a per-request ceiling, and shorter passages sound better.
- Keep the video identifier. Polling is the only way to retrieve the result, and it's what fires billing once it's ready.
Need a higher limit, a dedicated endpoint or a model outside this list? Talk to us at [email protected].