
Short prompts produce generic images. "coffee shop logo" gives the model almost nothing to work with — no name to render, no style, no composition, no palette. Modern image pipelines solve this with prompt expansion: a cheap LLM pass that turns a five-word prompt into a rich, model-appropriate paragraph before it hits the image API. Some platforms now bake an expander into specific models, which tells you how standard this preprocessing step has become.
This recipe builds that step yourself, as a small Python pipeline: an LLM enriches the user's short prompt, then the result goes to ideogram-v4 or flux-2 through hiapi's unified task API. You control the expansion rules per model — which matters, because what makes a great Ideogram prompt (exact text in quotes) is different from what makes a great FLUX.2 prompt (scene, lighting, camera).
Prerequisites: a hiapi API key (create one in the dashboard), Python 3.10+ with requests (pip install requests), and access to any chat-completions-compatible LLM endpoint for the expansion step (a small/cheap model is plenty — the expander only writes ~100 words at a time).
Two of the models on hiapi ship their own built-in expansion switch: qwen-image-2.0 has prompt_extend and flux-1.1-pro has prompt_upsampling. Flip those on and the platform enriches the prompt server-side.
But ideogram-v4 and flux-2/text-to-image — two of the strongest models for typography and prompt adherence respectively — expose no built-in expander. Their input schemas accept a prompt string and generation parameters, nothing else. If you want enriched prompts on these models, you bring your own expansion step. The upside: your own expander is model-aware, brand-aware, and debuggable. You see exactly what string was sent.
The whole trick is a string-in, string-out function. Here it is with a provider-agnostic LLM call — point LLM_BASE_URL / LLM_API_KEY / LLM_MODEL at whatever chat-completions-compatible endpoint you already run:
import os
import requests
LLM_BASE_URL = os.environ["LLM_BASE_URL"] # e.g. https://your-llm-endpoint/v1
LLM_API_KEY = os.environ["LLM_API_KEY"]
LLM_MODEL = os.environ["LLM_MODEL"]
EXPANDER_PROMPTS = {
# ideogram-v4 is a typography specialist: exact strings to render go in
# double quotes, and layout/style language matters more than camera language.
"ideogram-v4": (
"You expand short image prompts for a typography-focused image model. "
"Rules: keep the user's intent exactly; if the image should contain text, "
"put the exact words in double quotes; describe layout (centered, badge, "
"poster), lettering style, palette, and background; flat/vector terms are "
"good; no camera or lens language; output 60-100 words, one paragraph, "
"no preamble."
),
# flux-2 has strong prompt adherence for scenes: subject, setting, lighting,
# mood, camera. It has no negative prompt, so state what you want, not what
# you don't.
"flux-2/text-to-image": (
"You expand short image prompts for a high-fidelity text-to-image model. "
"Rules: keep the user's intent exactly; add subject detail, setting, "
"lighting, mood, composition and camera hints; state desired qualities "
"positively (the model has no negative prompt); output 80-120 words, one "
"paragraph, no preamble."
),
}
def expand_prompt(short_prompt: str, target_model: str) -> str:
"""LLM pass: short user prompt -> enriched, model-appropriate prompt."""
r = requests.post(
f"{LLM_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {LLM_API_KEY}"},
json={
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": EXPANDER_PROMPTS[target_model]},
{"role": "user", "content": short_prompt},
],
"temperature": 0.7,
},
timeout=60,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
Because the pipeline only needs str -> str, you can swap this for any expander — a bigger LLM, a fine-tuned one, or even a deterministic template for fixed brand campaigns.
Input: coffee shop logo
Expanded for ideogram-v4:
A minimalist circular badge logo for a specialty coffee shop named "Driftwood Coffee", with the words "DRIFTWOOD COFFEE" in clean uppercase serif lettering curving around a simple line-art coffee cup with three steam lines. Warm cream background, dark espresso-brown lettering and linework, flat vector style, high contrast, centered composition with generous margins.
Expanded for flux-2:
A cozy specialty coffee shop interior at golden hour, warm sunlight streaming through large front windows onto a walnut counter, a barista pouring latte art in a ceramic cup, shelves of amber glass jars and burlap coffee sacks behind, soft steam rising, shallow depth of field, 35mm photography look, warm inviting tones, sharp focus on the cup.
Same five-character intent, two completely different — and correctly targeted — prompts. The Ideogram version quotes the exact text to render (its specialty); the FLUX.2 version spends its words on scene and light (its specialty).
All hiapi generation models are called through one async endpoint: POST /v1/tasks creates a task, GET /v1/tasks/<id> polls it, and the finished asset appears at data.output[0].url.
import json
import os
import time
import requests
HIAPI_KEY = os.environ["HIAPI_API_KEY"]
TASKS = "https://api.hiapi.ai/v1/tasks"
HEADERS = {"Authorization": f"Bearer {HIAPI_KEY}", "Content-Type": "application/json"}
def create_task(model: str, input_obj: dict) -> str:
r = requests.post(TASKS, headers=HEADERS,
json={"model": model, "input": input_obj}, timeout=60)
data = r.json()
task_id = (data.get("data") or {}).get("taskId")
if not task_id:
raise RuntimeError(f"create task failed: {json.dumps(data)[:300]}")
return task_id
def wait_task(task_id: str, timeout_s: int = 300, poll_s: int = 5) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(f"{TASKS}/{task_id}",
headers={"Authorization": f"Bearer {HIAPI_KEY}"}, timeout=30)
task = r.json().get("data") or {}
if task.get("status") == "success":
return task
if task.get("status") == "fail":
err = task.get("error") or {}
raise RuntimeError(f"task failed: {err.get('code')} {err.get('message')}")
time.sleep(poll_s)
raise TimeoutError(f"task {task_id} still running after {timeout_s}s")
def generate(short_prompt: str, target_model: str) -> str:
expanded = expand_prompt(short_prompt, target_model)
print(f"expanded prompt -> {expanded}\n")
if target_model == "ideogram-v4":
input_obj = {
"prompt": expanded,
"rendering_speed": "BALANCED", # required: TURBO | BALANCED | QUALITY
"aspect_ratio": "1:1", # 1:1, 16:9, 4:3, 9:16, 3:4
}
else: # flux-2/text-to-image
input_obj = {
"prompt": expanded,
"aspect_ratio": "3:2", # 1:1, 16:9, 3:2, 2:3, 4:5, 5:4, 9:16, 3:4, 4:3, custom
"resolution": "1 MP", # '0.5 MP' | '1 MP' | '2 MP' | '4 MP'
}
task_id = create_task(target_model, input_obj)
task = wait_task(task_id)
url = task["output"][0]["url"]
# Output URLs expire — download immediately, don't hot-link them.
img = requests.get(url, timeout=120).content
fname = f"{target_model.replace('/', '-')}.png"
with open(fname, "wb") as f:
f.write(img)
return fname
if __name__ == "__main__":
print(generate("coffee shop logo", "ideogram-v4"))
print(generate("coffee shop logo", "flux-2/text-to-image"))
Run it:
export HIAPI_API_KEY="sk-..."
export LLM_BASE_URL="https://your-llm-endpoint/v1"
export LLM_API_KEY="..."
export LLM_MODEL="your-small-model"
python pipeline.py
The task API validates input strictly per model, and unknown fields are rejected, not ignored. If you send ideogram-v4 a field it doesn't know:
{
"code": 400,
"error_code": "INVALID_REQUEST",
"message": "invalid input: <root>: additional properties 'size' not allowed"
}
Three differences that matter in this pipeline:
rendering_speed is required on ideogram-v4 (TURBO/BALANCED/QUALITY — pricing differs per tier). flux-2 has no such field.1:1, 16:9, 4:3, 9:16, 3:4); flux-2 offers a wider set plus custom with explicit width/height (256–2048, multiples of 16). If your app lets users pick a ratio, map it per model before dispatch.resolution: '0.5 MP' to '4 MP'), and has no negative prompt — another reason the expander should phrase everything positively.Prefer callbacks over polling at volume. Add a callback block when creating the task and hiapi POSTs you the terminal state instead of you polling:
payload = {
"model": "flux-2/text-to-image",
"input": input_obj,
"callback": {"url": "https://your-domain.com/hiapi/callback", "when": "final"},
}
Polling is simpler and fine for local scripts, cron jobs, and low volume; callbacks avoid burning requests on long generations and give you exactly one notification per task. A common production pattern is callbacks as the primary channel with a slow reconciliation poll as backup.
Make retries idempotent. Send an Idempotency-Key header (up to 255 bytes, kept 24 h) when creating tasks. Retrying with the same key returns the original taskId (with an Idempotent-Replay: true response header) instead of creating — and billing — a duplicate task. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_MISMATCH; a retry that races the in-flight original gets 409 with Retry-After. Derive the key from your own job ID:
headers = {**HEADERS, "Idempotency-Key": f"expand-pipeline-{job_id}"}
Handle auth failures explicitly. A bad or under-privileged key fails with HTTP 401 and "code": "permission_denied" in the error body — catch it separately from generation errors, since retrying won't help:
if r.status_code == 401:
raise SystemExit("hiapi key invalid or lacks access to this model")
Persist outputs immediately. Task output URLs carry an expiry. Download the bytes as soon as the task succeeds and store them yourself.
Keep the expander on a leash. Cap the expansion at ~120 words, pass temperature ≤ 0.8, and log both the short and expanded prompt with the taskId. When a generation looks wrong, the first question is always "what did the model actually receive?" — with your own expander, you can answer it.
Mind the cost stack. You're paying for two calls per image now: a small LLM completion (typically negligible) plus the image generation — ideogram-v4 bills per image by rendering_speed tier, flux-2 per image by resolution tier. Current rates for both are on the pricing page.
prompt_extend) and flux-1.1-pro (prompt_upsampling)Can I send the same expanded prompt to both models? You can, but you'll leave quality on the table. Ideogram rewards quoted literal text and layout language; FLUX.2 rewards scene, lighting, and camera detail. Keeping one expander system prompt per model family costs a few lines and pays off every generation.
Do any hiapi models expand prompts for me?
Yes — qwen-image-2.0 accepts prompt_extend and flux-1.1-pro accepts prompt_upsampling. For ideogram-v4 and flux-2/text-to-image there is no built-in expander, which is exactly when this recipe applies.
How long should the expanded prompt be? 60–120 words is the sweet spot for this pipeline. Past that you dilute the signal, and some budget models truncate long prompts hard (flux-schnell, for instance, cuts around 256 tokens). Density beats length: every added phrase should constrain the image.
Does flux-2 support negative prompts? No. State what you want, not what you don't — and encode that rule in the expander's system prompt so it never emits "no text, no watermark"-style phrasing that the model would read as content.
What if the LLM changes the user's intent? Constrain it in the system prompt ("keep the user's intent exactly"), keep temperature moderate, and log the before/after pair. For high-stakes flows, show users the expanded prompt before generating, or offer a "use my prompt verbatim" toggle that bypasses the expander.
Why did my request fail with a 400 before any generation happened?
Schema validation. Each model validates input strictly — a missing required field (like rendering_speed on ideogram-v4) or an unknown field fails fast with error_code: "INVALID_REQUEST" and a message naming the offending field. These failures are free; fix the field and resubmit.
Key Takeaways