Surreal Image Generator paints pictures that are ordinary in every respect but one. In the browser you move through a strangeness space with seven axes — the operative break, the subject, the setting, the light, the palette, the medium, the register — and the app compiles those choices into a single block of prose, a painting brief, which it hands to an image model. The web app is a thin front end over a public HTTPS API. Everything it does you can do from a script, and this page is the whole contract.
Base URL:
https://api.skillsafe.ai/v1/app-api
Authentication is one header:
Authorization: Bearer YOUR_TOKEN
The token is app-scoped: it already says which app you are calling, so there is no slug header and no slug in the path. Get one from tokens.html, or mint a guest token with the call in step 1.
The envelope
Every response body, at every status code, is the same two-branch envelope. Read
data. Never read fields off the top level.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}
Check ok before you touch anything else. A refused run and a broken
connection do not look alike: the first is a well-formed envelope with ok: false,
the second is not JSON at all. The helper in step 2 handles both.
Endpoints
| Method and path | Costs credits | What it is for |
|---|---|---|
POST /guest | No | Mint an anonymous token. No Authorization header on this one. |
GET /me | No | Who the token belongs to, and the balance. |
POST /estimate | No | The hold a run of this shape would reserve. |
POST /run | Yes | Starts a job and returns job_id immediately. |
GET /jobs/{job_id} | No | Poll a job to a terminal state. |
POST /run-stream also exists on the platform. It is a text lane:
it streams token deltas over server-sent events, which is exactly nothing when the output is a
PNG. An image run gives you no partial picture and no progress deltas, so streaming buys you
no earlier information than polling does. Surreal Image Generator is /run plus
GET /jobs/{id}, and this page documents no SSE.
The body is two keys
This is the single most important thing on the page, so it goes before the tutorial rather than inside it. A Surreal Image Generator run body has exactly two keys:
{"instruction": "<the whole compiled painting brief, as plain prose>", "$model": "gpt-image"}
There is no task field. There is no style field. There is no
count field. There is no field for the seven axes, no field for aspect ratio and
no field for a seed. The axes are not parameters; they are sentences you have already written
into instruction.
Every additional key is concatenated into the text the image model sees, and the
image model paints the text it is given. This is why a stray key is not a harmless
no-op and not a 400. The API accepts the body, reserves the hold, runs the job
and returns 200 OK with a picture that has the words task surreal
lettered across the wall behind your chair. You paid for it, and it is ruined.
Wrong — four keys, and three of them become graffiti:
{"task": "surreal",
"instruction": "An ordinary wooden kitchen chair is filling with still water ...",
"style": "oil painting",
"count": 1,
"$model": "gpt-image"}
Right — the style and the count live in the prose, or they do not exist:
{"instruction": "An ordinary wooden kitchen chair is filling with still water ... Oil on canvas, visible brushwork, matte finish.",
"$model": "gpt-image"}
$model is what makes it an image run at all. Drop it and the
request goes to the app's text model, which will cheerfully write you three paragraphs
describing the painting you asked for. It also changes how the run is priced: per picture
rather than per token. Keep it, spell it exactly gpt-image, and note that
$model is not a legal identifier in Go, C# or Java, so those languages build the
body as a map or a string rather than a struct.
One run is one picture. There is no batch parameter, because there is no
parameter surface at all. Four pictures are four calls to /run, four holds and
four jobs to poll.
# Build the body in a file so the shell never has to quote the brief.
# BRIEF is the compiled painting brief from step 6.
cat > body.json <<'JSON'
{"instruction": "An ordinary wooden kitchen chair is filling with still water ...", "$model": "gpt-image"}
JSON
# Two keys. Confirm it before you spend anything on it:
jq -e 'keys == ["$model", "instruction"]' body.json >/dev/null \
&& echo "two keys, good" \
|| { echo "extra keys will be painted into the picture" >&2; exit 1; }
# BRIEF is the compiled painting brief from step 6.
BODY = {
"instruction": BRIEF,
"$model": "gpt-image",
}
# Two keys. Anything else here is text the model paints into the frame.
assert set(BODY) == {"instruction", "$model"}, "extra keys become graffiti"
// BRIEF is the compiled painting brief from step 6.
const BODY = {
instruction: BRIEF,
$model: "gpt-image",
};
// Two keys. Anything else here is text the model paints into the frame.
if (Object.keys(BODY).length !== 2) throw new Error("extra keys become graffiti");
// Brief is the compiled painting brief from step 6.
// "$model" is not a legal Go field name, so the body is a map, not a struct.
body := map[string]any{
"instruction": Brief,
"$model": "gpt-image",
}
// Two keys. Anything else here is text the model paints into the frame.
if len(body) != 2 {
log.Fatal("extra keys become graffiti")
}
// BRIEF is the compiled painting brief from step 6.
// The JDK ships no JSON writer, so the body is assembled as a string here; with
// Jackson or Gson build a Map and keep the two key names exactly as shown.
String body = "{\"instruction\": " + SurrealImageGenerator.jsonString(BRIEF)
+ ", \"$model\": \"gpt-image\"}";
// Two keys. Anything else in there is text the model paints into the frame.
# BRIEF is the compiled painting brief from step 6.
# Single-quote the '$model' key so nothing tries to interpolate it.
BODY = {
'instruction' => BRIEF,
'$model' => 'gpt-image'
}
# Two keys. Anything else here is text the model paints into the frame.
raise 'extra keys become graffiti' unless BODY.keys.sort == ['$model', 'instruction']
<?php
// $BRIEF is the compiled painting brief from step 6.
// Single-quote the '$model' key. Inside a double-quoted string PHP would try to
// interpolate a variable named $model and send you an empty key instead.
$body = [
'instruction' => $BRIEF,
'$model' => 'gpt-image',
];
// Two keys. Anything else here is text the model paints into the frame.
if (count($body) !== 2) {
throw new RuntimeException('extra keys become graffiti');
}
// Brief is the compiled painting brief from step 6.
// "$model" is not a legal C# member name, so use a dictionary rather than an
// anonymous object.
var body = new Dictionary<string, object> {
["instruction"] = Brief,
["$model"] = "gpt-image",
};
// Two keys. Anything else here is text the model paints into the frame.
if (body.Count != 2) throw new InvalidOperationException("extra keys become graffiti");
1. Get a token
Two ways in, and which one you want depends on whose credits are paying.
Your own token. Open tokens.html on this site, sign in, and copy what it shows you:
app token aut_…
That token is scoped to Surreal Image Generator and needs no companion header. Send it and you are calling this app.
A guest token. POST /guest is the only route on the API that
takes no Authorization header, and the only one that takes a slug. Post
{"slug": "surreal-image-generator"} and the response carries a fresh
token and a guest_id. A guest is an anonymous subject with the
publisher's sponsored allowance rather than an account, so it can run out mid-script and
cannot be topped up. It is the right choice for a demo and the wrong one for a batch.
A token can spend the credits of whoever minted it. Treat it as a password with a billing relationship attached: a secret manager, a keychain, or your CI provider's encrypted variables. Not a git repository, not a container image, not a log line, and never a front-end bundle — shipping it to a browser publishes it to everyone who opens the page. If one leaks, mint a fresh one from tokens.html; that is the whole remediation.
Every snippet below uses a constant named TOKEN holding the
literal YOUR_TOKEN, so the code reads clearly. In anything you deploy, replace
that literal with a lookup against your secret store.
# A. Your own token, copied from https://surreal-image-generator.skillsafe.ai/tokens.html
# The leading space keeps it out of shell history in most shells.
SF_TOKEN="YOUR_TOKEN"
# Or prompt for it, so it never lands in a file at all:
# read -rs -p 'Surreal Image Generator token: ' SF_TOKEN; echo
# B. Or mint a guest token. This is the one call with no Authorization header.
SF_TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug": "surreal-image-generator"}' | jq -r '.data.token')
case "$SF_TOKEN" in
aut_*) echo "ok: app-scoped token" ;;
*) echo "not an app token - copy one from /tokens.html" >&2; exit 1 ;;
esac
import requests
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://surreal-image-generator.skillsafe.ai/tokens.html
def load_token():
"""Return the app token.
Swap the constant for a real lookup: AWS Secrets Manager, Vault, Google
Secret Manager, the 1Password CLI, your CI provider's encrypted variables -
anything that is not a checked-in file.
"""
if not TOKEN.startswith("aut_"):
raise SystemExit("not an app-scoped token: copy one from /tokens.html")
return TOKEN
def guest_token():
"""Mint an anonymous token. The only call that sends no Authorization header."""
res = requests.post(BASE + "/guest", json={"slug": "surreal-image-generator"}, timeout=30)
env = res.json()
if not env.get("ok"):
raise SystemExit(env["error"]["message"])
return env["data"]["token"] # env["data"]["guest_id"] is also there
print("token loaded, length", len(load_token()))
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://surreal-image-generator.skillsafe.ai/tokens.html
/**
* Returns the app token. Replace the constant with a call into your secret
* store. Never bundle this into anything a browser downloads: a token in a
* front-end build is a token you have published.
*/
function loadToken() {
if (!TOKEN.startsWith("aut_")) {
throw new Error("not an app-scoped token: copy one from /tokens.html");
}
return TOKEN;
}
/** Mints an anonymous token. The only call that sends no Authorization header. */
async function guestToken() {
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "surreal-image-generator" }),
});
const env = await res.json();
if (!env.ok) throw new Error(env.error.message);
return env.data.token; // env.data.guest_id is also there
}
console.log("token loaded, length", loadToken().length);
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
)
const Base = "https://api.skillsafe.ai/v1/app-api"
// loadToken returns the app token. Replace the literal with a read from your
// secret store - Vault, Secrets Manager, a mounted file with 0400 on it.
// os.Getenv("SURREAL_FORGE_TOKEN") is fine for a throwaway script.
func loadToken() string {
token := "YOUR_TOKEN" // from https://surreal-image-generator.skillsafe.ai/tokens.html
if v := os.Getenv("SURREAL_FORGE_TOKEN"); v != "" {
token = v
}
if !strings.HasPrefix(token, "aut_") {
log.Fatal("not an app-scoped token: copy one from /tokens.html")
}
return token
}
// guestToken mints an anonymous token. The only call with no Authorization header.
func guestToken() (string, error) {
payload, _ := json.Marshal(map[string]string{"slug": "surreal-image-generator"})
res, err := http.Post(Base+"/guest", "application/json", bytes.NewReader(payload))
if err != nil {
return "", err
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil || !env.OK {
return "", fmt.Errorf("guest mint failed: HTTP %d", res.StatusCode)
}
return env.Data.Token, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Tokens {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
/**
* Returns the app token. Replace the constant with your secret manager's
* client. Do not put it in application.properties and do not log it.
*/
static String loadToken() {
if (!TOKEN.startsWith("aut_")) {
throw new IllegalStateException("not an app-scoped token: copy one from /tokens.html");
}
return TOKEN;
}
/** Mints an anonymous token. The only call that sends no Authorization header. */
static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\": \"surreal-image-generator\"}"))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
// Parse with Jackson or Gson: data.token, and data.guest_id alongside it.
return res.body();
}
public static void main(String[] args) {
System.out.println("token loaded, length " + loadToken().length());
}
}
require 'json'
require 'net/http'
require 'uri'
BASE = 'https://api.skillsafe.ai/v1/app-api'
TOKEN = 'YOUR_TOKEN' # from https://surreal-image-generator.skillsafe.ai/tokens.html
# Returns the app token. Replace the constant with Rails credentials, a Vault
# client, or whatever your deploy already uses for database passwords.
def load_token
abort 'not an app-scoped token: copy one from /tokens.html' unless TOKEN.start_with?('aut_')
TOKEN
end
# Mints an anonymous token. The only call that sends no Authorization header.
def guest_token
uri = URI("#{BASE}/guest")
res = Net::HTTP.post(uri, JSON.dump({ 'slug' => 'surreal-image-generator' }),
'Content-Type' => 'application/json')
env = JSON.parse(res.body)
raise env['error']['message'] unless env['ok']
env['data']['token'] # env['data']['guest_id'] is also there
end
puts "token loaded, length #{load_token.length}"
<?php
$BASE = 'https://api.skillsafe.ai/v1/app-api';
$TOKEN = 'YOUR_TOKEN'; // from https://surreal-image-generator.skillsafe.ai/tokens.html
/**
* Returns the app token. Replace the constant with your secret store's client.
* Keep it out of the document root and out of version control.
*/
function load_token(): string {
global $TOKEN;
if (!str_starts_with($TOKEN, 'aut_')) {
throw new RuntimeException('not an app-scoped token: copy one from /tokens.html');
}
return $TOKEN;
}
/** Mints an anonymous token. The only call that sends no Authorization header. */
function guest_token(): string {
global $BASE;
$ch = curl_init($BASE . '/guest');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['slug' => 'surreal-image-generator']),
CURLOPT_RETURNTRANSFER => true,
]);
$env = json_decode((string) curl_exec($ch), true);
curl_close($ch);
if (empty($env['ok'])) {
throw new RuntimeException('guest mint failed');
}
return $env['data']['token']; // $env['data']['guest_id'] is also there
}
echo 'token loaded, length ' . strlen(load_token()) . PHP_EOL;
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from https://surreal-image-generator.skillsafe.ai/tokens.html
// Returns the app token. Replace the constant with IConfiguration backed by
// Azure Key Vault, AWS Secrets Manager, or the .NET user-secrets store.
static string LoadToken()
{
if (!Token.StartsWith("aut_", StringComparison.Ordinal))
{
throw new InvalidOperationException("not an app-scoped token: copy one from /tokens.html");
}
return Token;
}
// Mints an anonymous token. The only call that sends no Authorization header.
static async Task<string> GuestToken()
{
using var http = new HttpClient();
var content = new StringContent("{\"slug\": \"surreal-image-generator\"}", Encoding.UTF8, "application/json");
using var res = await http.PostAsync(BaseUrl + "/guest", content);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var data = doc.RootElement.GetProperty("data");
return data.GetProperty("token").GetString(); // data.guest_id is also there
}
Console.WriteLine("token loaded, length " + LoadToken().Length);
2. A tiny client
Three things repeat on every call: the base URL, the bearer header, and unwrapping the
envelope. Write them once. The helper below takes a method and a path, sends JSON, raises when
ok is false, and returns data. Every later step assumes it exists and
calls it call.
Give the error object a real home while you are at it. error.code is the field
you branch on, error.message is for humans, and error.details carries
the specifics — such as the min_credits you fell short of.
Set a generous read timeout. A picture takes tens of seconds, and although
/run itself returns at once, a client that gives up after five seconds will
abandon jobs you have already paid for.
# Save as sf.sh and source it. Every later snippet uses sf_call.
SF_TOKEN="YOUR_TOKEN"
SF_BASE="https://api.skillsafe.ai/v1/app-api"
sf_call() { # sf_call METHOD PATH [BODY_FILE]
method="$1"; path="$2"; body_file="$3"
if [ -n "$body_file" ]; then
curl -sS -X "$method" "$SF_BASE$path" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$body_file"
else
curl -sS -X "$method" "$SF_BASE$path" \
-H "Authorization: Bearer $SF_TOKEN"
fi
}
# Unwrap with jq. .ok is the gate; everything you want is under .data.
sf_data() { jq -e 'if .ok then .data else error("app-api: " + .error.code + ": " + .error.message) end'; }
sf_call GET /me | sf_data
import base64
import time
import requests
TOKEN = "YOUR_TOKEN" # from https://surreal-image-generator.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class AppError(RuntimeError):
def __init__(self, err):
self.code = err.get("code", "unknown")
self.details = err.get("details") or {}
super().__init__("{0}: {1}".format(self.code, err.get("message", "")))
def call(method, path, body=None, headers=None):
"""Call the app API and return the unwrapped `data` object."""
hdrs = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
if headers:
hdrs.update(headers)
res = requests.request(method, BASE + path, json=body, headers=hdrs, timeout=300)
try:
env = res.json()
except ValueError:
raise AppError({"code": "transport",
"message": "HTTP %d: %s" % (res.status_code, res.text[:200])})
if not env.get("ok"):
raise AppError(env.get("error") or {"code": "unknown", "message": res.text[:200]})
return env["data"]
const TOKEN = "YOUR_TOKEN"; // from https://surreal-image-generator.skillsafe.ai/tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
class AppError extends Error {
constructor(err) {
super(`${err.code}: ${err.message}`);
this.name = "AppError";
this.code = err.code;
this.details = err.details || {};
}
}
/** Calls the app API and resolves with the unwrapped `data` object. */
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let env;
try {
env = JSON.parse(text);
} catch {
throw new AppError({ code: "transport", message: `HTTP ${res.status}: ${text.slice(0, 200)}` });
}
if (!env.ok) throw new AppError(env.error || { code: "unknown", message: text.slice(0, 200) });
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
Token = "YOUR_TOKEN" // from https://surreal-image-generator.skillsafe.ai/tokens.html
Base = "https://api.skillsafe.ai/v1/app-api"
)
var client = &http.Client{Timeout: 300 * time.Second}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *apiError) Error() string { return e.Code + ": " + e.Message }
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
// call sends JSON and returns the raw `data` object for the caller to unmarshal.
func call(method, path string, body any, extra map[string]string) (json.RawMessage, error) {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, Base+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+Token)
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, fmt.Errorf("transport: HTTP %d: %w", res.StatusCode, err)
}
if !env.OK {
if env.Error == nil {
return nil, fmt.Errorf("unknown: HTTP %d", res.StatusCode)
}
return nil, env.Error
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class SurrealImageGenerator {
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
static class AppException extends RuntimeException {
AppException(String body) { super("app-api: " + body); }
}
/**
* Sends JSON and returns the raw envelope. The JDK has no JSON parser, so hand
* the result to Jackson or Gson and read the `data` member from it.
*/
static String call(String method, String path, String jsonBody, Map<String, String> extra)
throws Exception {
HttpRequest.BodyPublisher pub = jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody);
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofMinutes(5))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub);
if (extra != null) extra.forEach(b::header);
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String body = res.body();
if (res.statusCode() >= 400 || body.contains("\"ok\":false") || body.contains("\"ok\": false")) {
throw new AppException(body);
}
return body;
}
/** Minimal JSON string escaper, used to build request bodies by hand. */
static String jsonString(String s) {
StringBuilder out = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\n' -> out.append("\\n");
default -> out.append(c);
}
}
return out.append('"').toString();
}
}
require 'base64'
require 'json'
require 'net/http'
require 'uri'
TOKEN = 'YOUR_TOKEN' # from https://surreal-image-generator.skillsafe.ai/tokens.html
BASE = 'https://api.skillsafe.ai/v1/app-api'
class AppError < StandardError
attr_reader :code, :details
def initialize(err)
@code = err['code'] || 'unknown'
@details = err['details'] || {}
super("#{@code}: #{err['message']}")
end
end
# Sends JSON and returns the unwrapped `data` object.
def call(method, path, body = nil, extra = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req['Authorization'] = "Bearer #{TOKEN}"
req['Content-Type'] = 'application/json'
extra.each { |k, v| req[k] = v }
req.body = JSON.dump(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req)
end
env = begin
JSON.parse(res.body)
rescue JSON::ParserError
raise AppError, { 'code' => 'transport', 'message' => "HTTP #{res.code}: #{res.body[0, 200]}" }
end
raise AppError, (env['error'] || { 'message' => res.body[0, 200] }) unless env['ok']
env['data']
end
<?php
$TOKEN = 'YOUR_TOKEN'; // from https://surreal-image-generator.skillsafe.ai/tokens.html
$BASE = 'https://api.skillsafe.ai/v1/app-api';
class AppError extends RuntimeException {
public string $code;
public array $details;
public function __construct(array $err) {
$this->code = $err['code'] ?? 'unknown';
$this->details = $err['details'] ?? [];
parent::__construct($this->code . ': ' . ($err['message'] ?? ''));
}
}
/** Sends JSON and returns the unwrapped `data` array. */
function sf_call(string $method, string $path, ?array $body = null, array $extra = []): array {
global $TOKEN, $BASE;
$headers = array_merge([
'Authorization: Bearer ' . $TOKEN,
'Content-Type: application/json',
], $extra);
$ch = curl_init($BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 300,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$env = json_decode((string) $raw, true);
if (!is_array($env)) {
throw new AppError(['code' => 'transport', 'message' => "HTTP $status"]);
}
if (empty($env['ok'])) {
throw new AppError($env['error'] ?? ['message' => substr((string) $raw, 0, 200)]);
}
return $env['data'];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string Token = "YOUR_TOKEN"; // from https://surreal-image-generator.skillsafe.ai/tokens.html
const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Token);
// Sends JSON and returns the unwrapped `data` element.
async Task<JsonElement> Call(HttpMethod method, string path, object body = null,
IEnumerable<KeyValuePair<string, string>> extra = null)
{
using var req = new HttpRequestMessage(method, BaseUrl + path);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
if (extra != null)
{
foreach (var h in extra) req.Headers.Add(h.Key, h.Value);
}
using var res = await http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonDocument doc;
try { doc = JsonDocument.Parse(text); }
catch (JsonException) { throw new Exception("transport: HTTP " + (int) res.StatusCode); }
using (doc)
{
var env = doc.RootElement;
if (!env.GetProperty("ok").GetBoolean())
{
var err = env.GetProperty("error");
throw new Exception(err.GetProperty("code").GetString() + ": "
+ err.GetProperty("message").GetString());
}
return env.GetProperty("data").Clone();
}
}
3. Check the session and the balance
GET /me is free, instant, and the right first call in any script. It answers
two questions: is this token attached to a real subject, and can it afford what you are about
to do.
/me returns exactly three fields. That is worth stating
flatly, because assuming otherwise is the most-copied mistake against this API:
| Field | Type | Meaning |
|---|---|---|
subject_type | string | "user" or "guest". |
subject_id | string | An opaque identifier. Stable, but not something you can look anything else up with. |
credits | number | The spendable balance right now. |
There is no email, no name, no display name and no id. Code that reaches
for me.email or me.id reads undefined and then fails somewhere far
away from the cause. Signed in means subject_type === "user"
— that comparison is the entire test. A guest subject has a balance too,
but it is the publisher's sponsored allowance rather than an account, and it can run out
halfway through a batch.
SF_TOKEN="YOUR_TOKEN"
curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $SF_TOKEN"
# {"ok":true,"data":{"subject_type":"user","subject_id":"sub_...","credits":41902}}
# Three fields. That is the whole response.
# Signed in is subject_type == "user". Nothing else in there says so.
curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $SF_TOKEN" \
| jq -e '.data.subject_type == "user"' >/dev/null \
&& echo "signed in" || echo "guest token"
me = call("GET", "/me")
# -> {"subject_type": "user", "subject_id": "sub_...", "credits": 41902}
# Those three keys are all of it. No email, no name, no id.
signed_in = me["subject_type"] == "user"
if not signed_in:
raise SystemExit("guest token - sign in at https://surreal-image-generator.skillsafe.ai/ for a real balance")
print("credits:", me["credits"])
const me = await call("GET", "/me");
// -> { subject_type: "user", subject_id: "sub_...", credits: 41902 }
// Those three keys are all of it. No email, no name, no id.
const signedIn = me.subject_type === "user";
if (!signedIn) {
throw new Error("guest token - sign in at https://surreal-image-generator.skillsafe.ai/ for a real balance");
}
console.log("credits:", me.credits);
type Me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits float64 `json:"credits"`
}
// Three fields, and that is the whole struct. No email, no name, no id.
raw, err := call("GET", "/me", nil, nil)
if err != nil {
log.Fatal(err)
}
var me Me
if err := json.Unmarshal(raw, &me); err != nil {
log.Fatal(err)
}
if me.SubjectType != "user" { // signed in is exactly this comparison
log.Fatal("guest token - sign in for a real balance")
}
fmt.Println("credits:", me.Credits)
// GET /me returns exactly subject_type, subject_id and credits.
// No email, no name, no id - do not model fields that are not there.
String envelope = SurrealImageGenerator.call("GET", "/me", null, null);
System.out.println(envelope);
// With Jackson:
// JsonNode data = new ObjectMapper().readTree(envelope).get("data");
// boolean signedIn = "user".equals(data.get("subject_type").asText());
// if (!signedIn) throw new IllegalStateException("guest token - sign in for a real balance");
// System.out.println("credits: " + data.get("credits").asLong());
me = call('GET', '/me')
# => {"subject_type"=>"user", "subject_id"=>"sub_...", "credits"=>41902}
# Those three keys are all of it. No email, no name, no id.
signed_in = me['subject_type'] == 'user'
abort 'guest token - sign in for a real balance' unless signed_in
puts "credits: #{me['credits']}"
<?php
$me = sf_call('GET', '/me');
// => ['subject_type' => 'user', 'subject_id' => 'sub_...', 'credits' => 41902]
// Those three keys are all of it. No email, no name, no id.
$signedIn = $me['subject_type'] === 'user';
if (!$signedIn) {
throw new RuntimeException('guest token - sign in for a real balance');
}
echo 'credits: ' . $me['credits'] . PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
// -> { "subject_type": "user", "subject_id": "sub_...", "credits": 41902 }
// Those three properties are all of it. No email, no name, no id.
var signedIn = me.GetProperty("subject_type").GetString() == "user";
if (!signedIn)
{
throw new InvalidOperationException("guest token - sign in for a real balance");
}
Console.WriteLine("credits: " + me.GetProperty("credits").GetInt64());
4. Estimate before you run
POST /estimate takes the same two-key body you would send to /run
and tells you what that run would reserve. It is free, it creates no job, and it charges
nothing.
| Field | Type | What it tells you |
|---|---|---|
model | string | The concrete model the run would reach. |
model_alias | string | The alias you asked for, echoed back — gpt-image here. Assert on it: if it comes back as something else, your $model key did not survive serialisation and you are about to buy paragraphs of prose instead of a painting. |
markup_bps | number | The publisher's markup in basis points, already folded into the figures below. |
hold_credits | number | What /run would reserve against your balance. |
min_credits | number | The balance floor. Below this the run is rejected with payment_required before anything starts. |
For an image model the hold is per picture and does not vary with prompt
length. A four-word instruction and the full compiled brief from step 6 reserve
exactly the same amount, because the renderer prices pictures, not tokens. Two consequences,
both useful. First, one estimate covers every brief you will ever send: call
it once at start-up and cache the answer for the life of the process. Second, you can estimate
with a placeholder string, before you have written a brief at all. A batch of N
pictures is N separate runs reserving N times hold_credits;
there is no volume discount and no batching endpoint.
hold_credits is a reservation, not a price. It is deliberately
pessimistic — a ceiling on what the run could conceivably cost — and it is taken
out of your available balance for the duration of the job, then released when the job settles.
What you actually pay comes back afterwards as charged_credits on the terminal
job, and it varies substantially from picture to picture: the same brief run twice will not
necessarily settle at the same figure, and the gap between the hold and the settlement is
routinely large. So budget your concurrency against hold_credits, since
that is what governs how many runs you can have in flight at once, and report your
spend from charged_credits, since that is the money.
Do not hard-code any figure from this page into a billing assumption. Read
hold_credits from a live estimate at start-up, and read
charged_credits off each finished job.
SF_TOKEN="YOUR_TOKEN"
# The instruction can be a placeholder: an image hold does not depend on it.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"instruction": "estimate probe", "$model": "gpt-image"}' | jq '.data'
# {
# "model": "gpt-image-...",
# "model_alias": "gpt-image",
# "markup_bps": 2000,
# "hold_credits": 2652,
# "min_credits": 2652
# }
# Four pictures reserve four times hold_credits, one run at a time.
# What you pay is charged_credits on each finished job, not this number.
# The instruction can be a placeholder: an image hold does not depend on it.
est = call("POST", "/estimate", {"instruction": "estimate probe", "$model": "gpt-image"})
assert est["model_alias"] == "gpt-image", "the $model key did not reach the API"
hold = est["hold_credits"]
print("model {0} reserves {1} credits per picture".format(est["model"], hold))
print("floor {0}, markup {1} bps".format(est["min_credits"], est["markup_bps"]))
# Budget the batch against the hold. What you pay is charged_credits, later, per job.
batch = 4
me = call("GET", "/me")
if me["credits"] < hold * batch:
raise SystemExit("need {0} credits to hold {1} pictures, have {2}".format(
hold * batch, batch, me["credits"]))
// The instruction can be a placeholder: an image hold does not depend on it.
const est = await call("POST", "/estimate", {
instruction: "estimate probe",
$model: "gpt-image",
});
if (est.model_alias !== "gpt-image") throw new Error("the $model key did not reach the API");
const hold = est.hold_credits;
console.log(`model ${est.model} reserves ${hold} credits per picture`);
console.log(`floor ${est.min_credits}, markup ${est.markup_bps} bps`);
// Budget the batch against the hold. What you pay is charged_credits, later, per job.
const batch = 4;
const me = await call("GET", "/me");
if (me.credits < hold * batch) {
throw new Error(`need ${hold * batch} credits to hold ${batch} pictures, have ${me.credits}`);
}
type Estimate struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps float64 `json:"markup_bps"`
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
}
// The instruction can be a placeholder: an image hold does not depend on it.
raw, err := call("POST", "/estimate", map[string]any{
"instruction": "estimate probe",
"$model": "gpt-image",
}, nil)
if err != nil {
log.Fatal(err)
}
var est Estimate
if err := json.Unmarshal(raw, &est); err != nil {
log.Fatal(err)
}
if est.ModelAlias != "gpt-image" {
log.Fatal("the $model key did not reach the API")
}
// Budget concurrency against the hold; report spend from charged_credits later.
fmt.Printf("%s reserves %.0f per picture, floor %.0f, markup %.0f bps\n",
est.Model, est.HoldCredits, est.MinCredits, est.MarkupBps)
// The instruction can be a placeholder: an image hold does not depend on it.
String body = "{\"instruction\": \"estimate probe\", \"$model\": \"gpt-image\"}";
String envelope = SurrealImageGenerator.call("POST", "/estimate", body, null);
System.out.println(envelope);
// With Jackson:
// JsonNode d = new ObjectMapper().readTree(envelope).get("data");
// if (!"gpt-image".equals(d.get("model_alias").asText())) {
// throw new IllegalStateException("the $model key did not reach the API");
// }
// long hold = d.get("hold_credits").asLong(); // reserved, per picture
// long floor = d.get("min_credits").asLong(); // balance floor
// long markup = d.get("markup_bps").asLong();
// // Budget concurrency against hold; read charged_credits off the finished job.
# The instruction can be a placeholder: an image hold does not depend on it.
est = call('POST', '/estimate', { 'instruction' => 'estimate probe', '$model' => 'gpt-image' })
raise 'the $model key did not reach the API' unless est['model_alias'] == 'gpt-image'
hold = est['hold_credits']
puts "model #{est['model']} reserves #{hold} credits per picture"
puts "floor #{est['min_credits']}, markup #{est['markup_bps']} bps"
# Budget the batch against the hold. What you pay is charged_credits, later, per job.
batch = 4
me = call('GET', '/me')
abort "need #{hold * batch} credits to hold #{batch} pictures, have #{me['credits']}" if
me['credits'] < hold * batch
<?php
// The instruction can be a placeholder: an image hold does not depend on it.
$est = sf_call('POST', '/estimate', [
'instruction' => 'estimate probe',
'$model' => 'gpt-image',
]);
if ($est['model_alias'] !== 'gpt-image') {
throw new RuntimeException('the $model key did not reach the API');
}
$hold = $est['hold_credits'];
printf("model %s reserves %s credits per picture\n", $est['model'], $hold);
printf("floor %s, markup %s bps\n", $est['min_credits'], $est['markup_bps']);
// Budget the batch against the hold. What you pay is charged_credits, later, per job.
$batch = 4;
$me = sf_call('GET', '/me');
if ($me['credits'] < $hold * $batch) {
throw new RuntimeException("need " . ($hold * $batch) . " credits to hold $batch pictures");
}
// The instruction can be a placeholder: an image hold does not depend on it.
var est = await Call(HttpMethod.Post, "/estimate", new Dictionary<string, object> {
["instruction"] = "estimate probe",
["$model"] = "gpt-image",
});
if (est.GetProperty("model_alias").GetString() != "gpt-image")
{
throw new InvalidOperationException("the $model key did not reach the API");
}
var hold = est.GetProperty("hold_credits").GetInt64();
Console.WriteLine($"{est.GetProperty("model").GetString()} reserves {hold} credits per picture");
Console.WriteLine($"floor {est.GetProperty("min_credits").GetInt64()}, "
+ $"markup {est.GetProperty("markup_bps").GetInt64()} bps");
// Budget the batch against the hold. What you pay is charged_credits, later, per job.
var batch = 4;
var me = await Call(HttpMethod.Get, "/me");
if (me.GetProperty("credits").GetInt64() < hold * batch)
{
throw new InvalidOperationException($"need {hold * batch} credits to hold {batch} pictures");
}
5. Paint a picture: /run, then poll
POST /run does not wait. It reserves the hold, queues the job, and returns
{"job_id": "..."} straight away. You then poll GET /jobs/{job_id}
until status is succeeded or failed.
| Field on the job | When it appears | Meaning |
|---|---|---|
job_id | Always | The handle you poll with. |
status | Always | queued, running, succeeded, failed. The first two are non-terminal; keep polling. |
output | On succeeded | Holds images and output. |
charged_credits | On any terminal status | What you actually paid once the hold was released. This, not hold_credits, is your spend. |
error | On failed | The same code and message shape as the envelope error. |
Poll every 1.5 seconds and give up after 240. A painting normally lands in
20 to 40 seconds. Polling tighter buys you nothing and will earn you a
rate_limited; a shorter ceiling abandons jobs that were about to succeed and that
you have already paid for.
The picture is base64 in the job output. Read
output.images[0].b64 for the bytes and
output.images[0].content_type for what to name the file. Decode the base64 and
write it as binary; do not write it as text and do not run it through a string-encoding step
on the way to disk.
output.output is the empty string on an image run. That field
is where the platform's text lane puts its answer, and every habit carried over from a text app
reaches for it first. Here it is present, it is a string, and it is "" — not
null, not missing — so a truthiness check on it will never tell you the run failed. It
will just quietly hand you nothing. Look in images, always.
Idempotency
Send an Idempotency-Key header on POST /run and a retry after a
dropped connection attaches to the job you already started instead of paying for a second one.
But replaying a key returns the original job even when that job failed. A key
derived only from your inputs therefore turns a transient renderer failure into a permanent
one: every retry re-serves the same dead job, forever.
Derive the key from a hash of the brief plus an attempt counter. The hash makes
accidental duplicates of the same request collapse into one charge; the counter makes a
deliberate retry a genuinely new run. Something of the shape
sha256(brief)[:16] + ":attempt-" + n is enough.
SF_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# Exactly two keys. The brief goes in a file so the shell never has to quote it.
cat > body.json <<'JSON'
{"instruction": "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.", "$model": "gpt-image"}
JSON
# Hash of the brief, plus an attempt counter. The counter is what lets a
# deliberate retry be a new run instead of a replay of a failed one.
ATTEMPT=1
DIGEST=$(jq -r '.instruction' body.json | shasum -a 256 | cut -c1-16)
IDEM="$DIGEST:attempt-$ATTEMPT"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM" \
--data-binary @body.json | jq -r '.data.job_id')
echo "job $JOB"
DEADLINE=$(( $(date +%s) + 240 ))
while :; do
RES=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $SF_TOKEN")
STATUS=$(printf '%s' "$RES" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { printf '%s' "$RES" | jq '.data.error'; exit 1; }
[ "$(date +%s)" -ge "$DEADLINE" ] && { echo "timed out after 240s" >&2; exit 1; }
sleep 1.5
done
printf '%s' "$RES" | jq -r '.data.output.images[0].b64' | base64 --decode > chair.png
printf '%s' "$RES" | jq -r '"type \(.data.output.images[0].content_type), charged \(.data.charged_credits)"'
# .data.output.output is "" on an image run - the picture is only in images[0].
import base64
import hashlib
import time
BRIEF = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image."
def paint(brief, attempt=1, path="chair.png"):
# Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
digest = hashlib.sha256(brief.encode("utf-8")).hexdigest()[:16]
key = "{0}:attempt-{1}".format(digest, attempt)
started = call("POST", "/run",
{"instruction": brief, "$model": "gpt-image"}, # exactly two keys
headers={"Idempotency-Key": key})
job_id = started["job_id"]
deadline = time.time() + 240 # a painting normally lands in 20-40s
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise AppError(job.get("error") or {"code": "failed", "message": job_id})
if time.time() > deadline:
raise TimeoutError("job {0} still {1} after 240s".format(job_id, job["status"]))
time.sleep(1.5)
image = job["output"]["images"][0]
with open(path, "wb") as fh: # binary, always
fh.write(base64.b64decode(image["b64"]))
# job["output"]["output"] is "" here - do not look for the picture in it.
print(path, image["content_type"], "charged", job["charged_credits"])
return path
paint(BRIEF)
import { writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const BRIEF = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function paint(brief, attempt = 1, path = "chair.png") {
// Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
const digest = createHash("sha256").update(brief).digest("hex").slice(0, 16);
const key = `${digest}:attempt-${attempt}`;
const started = await call(
"POST",
"/run",
{ instruction: brief, $model: "gpt-image" }, // exactly two keys
{ "Idempotency-Key": key },
);
const deadline = Date.now() + 240_000; // a painting normally lands in 20-40s
let job;
for (;;) {
job = await call("GET", `/jobs/${started.job_id}`);
if (job.status === "succeeded") break;
if (job.status === "failed") {
throw new AppError(job.error || { code: "failed", message: started.job_id });
}
if (Date.now() > deadline) {
throw new Error(`job ${started.job_id} still ${job.status} after 240s`);
}
await sleep(1500);
}
const image = job.output.images[0];
await writeFile(path, Buffer.from(image.b64, "base64")); // binary, always
// job.output.output is "" here - the picture is only in images[0].
console.log(path, image.content_type, "charged", job.charged_credits);
return path;
}
await paint(BRIEF);
const Brief = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image."
type Image struct {
B64 string `json:"b64"`
ContentType string `json:"content_type"`
}
type Job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Output struct {
Images []Image `json:"images"`
Output string `json:"output"` // "" on an image run
} `json:"output"`
ChargedCredits float64 `json:"charged_credits"`
Error *apiError `json:"error"`
}
func paint(brief, path string, attempt int) error {
// Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
sum := sha256.Sum256([]byte(brief))
key := fmt.Sprintf("%x:attempt-%d", sum[:8], attempt)
body := map[string]any{"instruction": brief, "$model": "gpt-image"} // exactly two keys
raw, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": key})
if err != nil {
return err
}
var started struct {
JobID string `json:"job_id"`
}
if err := json.Unmarshal(raw, &started); err != nil {
return err
}
deadline := time.Now().Add(240 * time.Second) // a painting lands in 20-40s
var job Job
for {
raw, err = call("GET", "/jobs/"+started.JobID, nil, nil)
if err != nil {
return err
}
if err := json.Unmarshal(raw, &job); err != nil {
return err
}
if job.Status == "succeeded" {
break
}
if job.Status == "failed" {
return job.Error
}
if time.Now().After(deadline) {
return fmt.Errorf("job %s still %s after 240s", started.JobID, job.Status)
}
time.Sleep(1500 * time.Millisecond)
}
pixels, err := base64.StdEncoding.DecodeString(job.Output.Images[0].B64)
if err != nil {
return err
}
if err := os.WriteFile(path, pixels, 0o644); err != nil {
return err
}
// job.Output.Output is "" here - the picture is only in Images[0].
fmt.Println(path, job.Output.Images[0].ContentType, "charged", job.ChargedCredits)
return nil
}
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Map;
// The compiled painting brief from step 6.
static final String BRIEF = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.";
static Path paint(String brief, int attempt, Path out) throws Exception {
// Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
byte[] sum = MessageDigest.getInstance("SHA-256").digest(brief.getBytes("UTF-8"));
String key = HexFormat.of().formatHex(sum).substring(0, 16) + ":attempt-" + attempt;
Map<String, String> idem = Map.of("Idempotency-Key", key);
// Exactly two keys.
String body = "{\"instruction\": " + SurrealImageGenerator.jsonString(brief) + ", \"$model\": \"gpt-image\"}";
String startedEnv = SurrealImageGenerator.call("POST", "/run", body, idem);
// Parse with Jackson or Gson; shown here as the fields you need.
String jobId = readString(startedEnv, "data", "job_id");
long deadline = System.currentTimeMillis() + 240_000L; // 20-40s is typical
String jobEnv;
String status;
while (true) {
jobEnv = SurrealImageGenerator.call("GET", "/jobs/" + jobId, null, null);
status = readString(jobEnv, "data", "status");
if ("succeeded".equals(status)) break;
if ("failed".equals(status)) throw new IllegalStateException("job failed: " + jobEnv);
if (System.currentTimeMillis() > deadline) {
throw new IllegalStateException("job " + jobId + " still " + status + " after 240s");
}
Thread.sleep(1500L);
}
// data.output.images[0].b64, and data.output.output is "" on an image run.
String b64 = readString(jobEnv, "data", "output", "images", "0", "b64");
Files.write(out, Base64.getDecoder().decode(b64));
System.out.println(out + " charged " + readString(jobEnv, "data", "charged_credits"));
return out;
}
require 'digest'
BRIEF = 'An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.'
def paint(brief, attempt = 1, path = 'chair.png')
# Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
key = "#{Digest::SHA256.hexdigest(brief)[0, 16]}:attempt-#{attempt}"
# Exactly two keys.
started = call('POST', '/run',
{ 'instruction' => brief, '$model' => 'gpt-image' },
{ 'Idempotency-Key' => key })
job_id = started['job_id']
deadline = Time.now + 240 # a painting normally lands in 20-40s
job = nil
loop do
job = call('GET', "/jobs/#{job_id}")
break if job['status'] == 'succeeded'
raise AppError, (job['error'] || { 'code' => 'failed', 'message' => job_id }) if job['status'] == 'failed'
raise "job #{job_id} still #{job['status']} after 240s" if Time.now > deadline
sleep 1.5
end
image = job['output']['images'][0]
File.binwrite(path, Base64.decode64(image['b64'])) # binary, always
# job['output']['output'] is '' here - the picture is only in images[0].
puts "#{path} #{image['content_type']} charged #{job['charged_credits']}"
path
end
paint(BRIEF)
<?php
$BRIEF = 'An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.';
function paint(string $brief, int $attempt = 1, string $path = 'chair.png'): string {
// Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
$key = substr(hash('sha256', $brief), 0, 16) . ':attempt-' . $attempt;
// Exactly two keys. Single-quote '$model' so PHP does not interpolate it.
$started = sf_call('POST', '/run', [
'instruction' => $brief,
'$model' => 'gpt-image',
], ['Idempotency-Key: ' . $key]);
$jobId = $started['job_id'];
$deadline = time() + 240; // a painting normally lands in 20-40s
while (true) {
$job = sf_call('GET', '/jobs/' . $jobId);
if ($job['status'] === 'succeeded') break;
if ($job['status'] === 'failed') {
throw new AppError($job['error'] ?? ['code' => 'failed', 'message' => $jobId]);
}
if (time() > $deadline) {
throw new RuntimeException("job $jobId still {$job['status']} after 240s");
}
usleep(1_500_000);
}
$image = $job['output']['images'][0];
file_put_contents($path, base64_decode($image['b64']));
// $job['output']['output'] is '' here - look in images[0] only.
printf("%s %s charged %s\n", $path, $image['content_type'], $job['charged_credits']);
return $path;
}
paint($BRIEF);
using System.IO;
using System.Security.Cryptography;
const string Brief = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.";
async Task<string> Paint(string brief, int attempt = 1, string path = "chair.png")
{
// Hash of the brief plus an attempt counter: duplicates collapse, retries do not.
var sum = SHA256.HashData(Encoding.UTF8.GetBytes(brief));
var key = Convert.ToHexString(sum)[..16].ToLowerInvariant() + ":attempt-" + attempt;
// Exactly two keys.
var body = new Dictionary<string, object> {
["instruction"] = brief,
["$model"] = "gpt-image",
};
var started = await Call(HttpMethod.Post, "/run", body,
new[] { new KeyValuePair<string, string>("Idempotency-Key", key) });
var jobId = started.GetProperty("job_id").GetString();
var deadline = DateTime.UtcNow.AddSeconds(240); // a painting lands in 20-40s
JsonElement job;
while (true)
{
job = await Call(HttpMethod.Get, "/jobs/" + jobId);
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception("job failed: " + job.GetProperty("error"));
if (DateTime.UtcNow > deadline) throw new TimeoutException("job " + jobId + " still " + status);
await Task.Delay(1500);
}
var image = job.GetProperty("output").GetProperty("images")[0];
await File.WriteAllBytesAsync(path, Convert.FromBase64String(image.GetProperty("b64").GetString()));
// output.output is "" here - the picture is only in images[0].
Console.WriteLine($"{path} {image.GetProperty("content_type").GetString()} "
+ $"charged {job.GetProperty("charged_credits").GetInt64()}");
return path;
}
await Paint(Brief);
6. Composing a brief that reads as a painting
The API contract is four paragraphs long. The interesting problem is the other side of it:
what to put in instruction. Surreal Image Generator's whole design assumption is that
strangeness is a quantity, and that the useful setting is one. A
picture with a single impossible thing in it is unsettling. A picture with six is decoration.
So state one operative break, plainly, in the first sentence, and in ordinary words. Not "surreal", not "dreamlike", not "impossible" — those are labels for the effect, and a model given a label paints the label: melting clocks, floating rocks, the whole exhausted vocabulary. Say instead exactly what is happening, as flatly as you would report a leak. A chair is filling with water. That is a break. A surreal dreamscape of transformation is not a break; it is a mood board.
Then make everything else ordinary, and say so explicitly. The break only registers as a break against a background that is behaving. This is the sentence most people leave out, and leaving it out is why their results look like album covers: given no instruction to stay still, a model will make the floor undulate too, and then nothing is strange because everything is.
Name a mundane anchor object — a specific, unremarkable, nameable thing that the break is not happening to. A chipped enamel kettle. A radiator. A dish towel over the oven handle. The anchor does two jobs: it fixes the scale and the setting for free, and it gives the eye somewhere honest to stand while it works out what is wrong. Vague nouns cannot do this. "Kitchen items" anchors nothing.
Then the last four axes, one short sentence each. Light — direction, time of day, hardness; flat sideways light reads as documentary, which helps. Palette — name three colours, not a temperature; three named colours constrain the whole canvas, "warm tones" constrains nothing. Medium — the physical process and its surface: oil on canvas with visible brushwork, gouache, a matte colour photograph, silverpoint. Register — the attitude the picture takes toward its own contents. Calm and matter-of-fact is the strongest register for this material, because the deadpan is what makes it land; a picture that is visibly excited about being strange has already told you what to feel.
Close with a short negative clause. Image models letter words onto walls, canvases and signage unprompted, and a watermark across your chair is a wasted run.
A worked example, about ninety words, which is roughly the right length — long enough to fix all seven axes, short enough that no single sentence gets outvoted:
An ordinary wooden kitchen chair is filling with still water, held
inside its own seat as though the seat were a glass. Nothing else in
the room is strange: linoleum floor, a radiator under the window, a
chipped enamel kettle on the counter, a dish towel folded over the
oven handle. Flat late-afternoon light comes sideways through one
window. The palette is muted ochre, dishwater grey and one cold blue.
Oil on canvas, visible brushwork, matte finish. Calm and
matter-of-fact, as if nobody has noticed. No text, letters, captions,
watermarks or signatures anywhere in the image.
Read it back and notice what it does not contain: no adjective for the overall effect, no second impossibility, no camera settings, no artist's name, and no parameters. It is one block of plain prose, which is the only thing the API takes. The code below assembles it from the seven axes so you can vary one at a time — hold six fixed, sweep the seventh, and you have a controlled experiment instead of a slot machine.
# Seven axes, one sentence each, in this order. jq joins them into the brief
# and writes the two-key body in the same pass.
cat > axes.json <<'JSON'
{
"break": "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
"subject": "The chair is the subject, alone and seen straight on, at the centre of the frame.",
"setting": "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
"light": "Flat late-afternoon light comes sideways through one window.",
"palette": "The palette is muted ochre, dishwater grey and one cold blue.",
"medium": "Oil on canvas, visible brushwork, matte finish.",
"register": "Calm and matter-of-fact, as if nobody has noticed."
}
JSON
NEG="No text, letters, captions, watermarks or signatures anywhere in the image."
jq --arg neg "$NEG" '
[.break, .subject, .setting, .light, .palette, .medium, .register, $neg]
| join(" ")
| {instruction: ., "$model": "gpt-image"}
' axes.json > body.json
jq -r '.instruction' body.json | wc -w # aim for roughly 90
# Seven axes, one sentence each. Sweep one, hold the other six, and you have a
# controlled experiment rather than a slot machine.
AXES = {
"break": "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
"subject": "The chair is the subject, alone and seen straight on, at the centre of the frame.",
"setting": "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
"light": "Flat late-afternoon light comes sideways through one window.",
"palette": "The palette is muted ochre, dishwater grey and one cold blue.",
"medium": "Oil on canvas, visible brushwork, matte finish.",
"register": "Calm and matter-of-fact, as if nobody has noticed.",
}
ORDER = ["break", "subject", "setting", "light", "palette", "medium", "register"]
NEGATIVES = "No text, letters, captions, watermarks or signatures anywhere in the image."
def compile_brief(axes):
"""Join the seven axes into the one block of prose the API takes."""
return " ".join([axes[k] for k in ORDER] + [NEGATIVES])
BRIEF = compile_brief(AXES)
print(len(BRIEF.split()), "words") # aim for roughly 90
# Sweep the medium, hold the rest.
for medium in ["Oil on canvas, visible brushwork, matte finish.",
"A matte colour photograph on 35mm film, slight grain.",
"Gouache on paper, flat opaque colour, soft edges."]:
variant = dict(AXES, medium=medium)
paint(compile_brief(variant))
// Seven axes, one sentence each. Sweep one, hold the other six.
const AXES = {
break: "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
subject: "The chair is the subject, alone and seen straight on, at the centre of the frame.",
setting: "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
light: "Flat late-afternoon light comes sideways through one window.",
palette: "The palette is muted ochre, dishwater grey and one cold blue.",
medium: "Oil on canvas, visible brushwork, matte finish.",
register: "Calm and matter-of-fact, as if nobody has noticed.",
};
const ORDER = ["break", "subject", "setting", "light", "palette", "medium", "register"];
const NEGATIVES = "No text, letters, captions, watermarks or signatures anywhere in the image.";
/** Joins the seven axes into the one block of prose the API takes. */
function compileBrief(axes) {
return [...ORDER.map((k) => axes[k]), NEGATIVES].join(" ");
}
const BRIEF = compileBrief(AXES);
console.log(BRIEF.split(/\s+/).length, "words"); // aim for roughly 90
// Sweep the medium, hold the rest.
for (const medium of [
"Oil on canvas, visible brushwork, matte finish.",
"A matte colour photograph on 35mm film, slight grain.",
"Gouache on paper, flat opaque colour, soft edges.",
]) {
await paint(compileBrief({ ...AXES, medium }));
}
// Seven axes, one sentence each. Sweep one, hold the other six.
var axes = map[string]string{
"break": "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
"subject": "The chair is the subject, alone and seen straight on, at the centre of the frame.",
"setting": "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
"light": "Flat late-afternoon light comes sideways through one window.",
"palette": "The palette is muted ochre, dishwater grey and one cold blue.",
"medium": "Oil on canvas, visible brushwork, matte finish.",
"register": "Calm and matter-of-fact, as if nobody has noticed.",
}
var order = []string{"break", "subject", "setting", "light", "palette", "medium", "register"}
const negatives = "No text, letters, captions, watermarks or signatures anywhere in the image."
// compileBrief joins the seven axes into the one block of prose the API takes.
func compileBrief(axes map[string]string) string {
parts := make([]string, 0, len(order)+1)
for _, k := range order {
parts = append(parts, axes[k])
}
return strings.Join(append(parts, negatives), " ")
}
func sweepMedium() error {
for _, medium := range []string{
"Oil on canvas, visible brushwork, matte finish.",
"A matte colour photograph on 35mm film, slight grain.",
"Gouache on paper, flat opaque colour, soft edges.",
} {
variant := make(map[string]string, len(axes))
for k, v := range axes {
variant[k] = v
}
variant["medium"] = medium
if err := paint(compileBrief(variant), "out.png", 1); err != nil {
return err
}
}
return nil
}
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.StringJoiner;
// Seven axes, one sentence each, in this order. Sweep one, hold the other six.
static final Map<String, String> AXES = new LinkedHashMap<>();
static {
AXES.put("break", "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.");
AXES.put("subject", "The chair is the subject, alone and seen straight on, at the centre of the frame.");
AXES.put("setting", "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.");
AXES.put("light", "Flat late-afternoon light comes sideways through one window.");
AXES.put("palette", "The palette is muted ochre, dishwater grey and one cold blue.");
AXES.put("medium", "Oil on canvas, visible brushwork, matte finish.");
AXES.put("register", "Calm and matter-of-fact, as if nobody has noticed.");
}
static final String NEGATIVES =
"No text, letters, captions, watermarks or signatures anywhere in the image.";
/** Joins the seven axes into the one block of prose the API takes. */
static String compileBrief(Map<String, String> axes) {
StringJoiner j = new StringJoiner(" ");
for (String v : axes.values()) j.add(v); // LinkedHashMap keeps the order
j.add(NEGATIVES);
return j.toString();
}
// Sweep the medium, hold the rest.
static void sweepMedium() throws Exception {
for (String medium : new String[] {
"Oil on canvas, visible brushwork, matte finish.",
"A matte colour photograph on 35mm film, slight grain.",
"Gouache on paper, flat opaque colour, soft edges." }) {
Map<String, String> variant = new LinkedHashMap<>(AXES);
variant.put("medium", medium);
paint(compileBrief(variant), 1, Path.of("out.png"));
}
}
# Seven axes, one sentence each. A Ruby hash keeps insertion order, so the
# order below is the order the brief reads in.
AXES = {
'break' => 'An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.',
'subject' => 'The chair is the subject, alone and seen straight on, at the centre of the frame.',
'setting' => 'Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.',
'light' => 'Flat late-afternoon light comes sideways through one window.',
'palette' => 'The palette is muted ochre, dishwater grey and one cold blue.',
'medium' => 'Oil on canvas, visible brushwork, matte finish.',
'register' => 'Calm and matter-of-fact, as if nobody has noticed.'
}.freeze
NEGATIVES = 'No text, letters, captions, watermarks or signatures anywhere in the image.'
# Joins the seven axes into the one block of prose the API takes.
def compile_brief(axes)
(axes.values + [NEGATIVES]).join(' ')
end
BRIEF = compile_brief(AXES)
puts "#{BRIEF.split.length} words" # aim for roughly 90
# Sweep the medium, hold the rest.
['Oil on canvas, visible brushwork, matte finish.',
'A matte colour photograph on 35mm film, slight grain.',
'Gouache on paper, flat opaque colour, soft edges.'].each do |medium|
paint(compile_brief(AXES.merge('medium' => medium)))
end
<?php
// Seven axes, one sentence each. PHP arrays keep insertion order, so the order
// below is the order the brief reads in.
$AXES = [
'break' => 'An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.',
'subject' => 'The chair is the subject, alone and seen straight on, at the centre of the frame.',
'setting' => 'Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.',
'light' => 'Flat late-afternoon light comes sideways through one window.',
'palette' => 'The palette is muted ochre, dishwater grey and one cold blue.',
'medium' => 'Oil on canvas, visible brushwork, matte finish.',
'register' => 'Calm and matter-of-fact, as if nobody has noticed.',
];
const NEGATIVES = 'No text, letters, captions, watermarks or signatures anywhere in the image.';
/** Joins the seven axes into the one block of prose the API takes. */
function compile_brief(array $axes): string {
return implode(' ', array_merge(array_values($axes), [NEGATIVES]));
}
$BRIEF = compile_brief($AXES);
echo str_word_count($BRIEF) . " words\n"; // aim for roughly 90
// Sweep the medium, hold the rest.
foreach ([
'Oil on canvas, visible brushwork, matte finish.',
'A matte colour photograph on 35mm film, slight grain.',
'Gouache on paper, flat opaque colour, soft edges.',
] as $medium) {
paint(compile_brief(array_merge($AXES, ['medium' => $medium])));
}
using System.Collections.Generic;
using System.Linq;
// Seven axes, one sentence each. A Dictionary preserves insertion order here in
// practice, but the explicit Order array is what actually fixes the sequence.
var axes = new Dictionary<string, string> {
["break"] = "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
["subject"] = "The chair is the subject, alone and seen straight on, at the centre of the frame.",
["setting"] = "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
["light"] = "Flat late-afternoon light comes sideways through one window.",
["palette"] = "The palette is muted ochre, dishwater grey and one cold blue.",
["medium"] = "Oil on canvas, visible brushwork, matte finish.",
["register"] = "Calm and matter-of-fact, as if nobody has noticed.",
};
var order = new[] { "break", "subject", "setting", "light", "palette", "medium", "register" };
const string Negatives = "No text, letters, captions, watermarks or signatures anywhere in the image.";
// Joins the seven axes into the one block of prose the API takes.
string CompileBrief(Dictionary<string, string> a) =>
string.Join(" ", order.Select(k => a[k]).Append(Negatives));
// Sweep the medium, hold the rest.
foreach (var medium in new[] {
"Oil on canvas, visible brushwork, matte finish.",
"A matte colour photograph on 35mm film, slight grain.",
"Gouache on paper, flat opaque colour, soft edges." })
{
var variant = new Dictionary<string, string>(axes) { ["medium"] = medium };
await Paint(CompileBrief(variant));
}
7. Errors and retries
An error is a normal envelope with ok: false. Branch on
error.code, not on the HTTP status — the status is a summary, the code is
the fact.
| HTTP | error.code | Retry? | What to do |
|---|---|---|---|
| 401 | unauthorized | No | The token is missing, malformed or no longer valid. Retrying cannot fix it. Mint a fresh one from tokens.html and check the header really reads Bearer followed by the token. |
| 402 | payment_required | No | The balance is below min_credits, so the hold could not be taken and nothing ran. Top up, or lower your in-flight count — concurrent holds are the usual cause. error.details carries the shortfall. |
| 400 | validation_error | No | The body is malformed. Here that almost always means a missing or mangled $model, or an instruction that is not a string. Fix the body; retrying it unchanged will fail identically. |
| 429 | rate_limited | Yes | Too many requests. Back off exponentially with jitter. Polling faster than every 1.5 seconds is the most common way to land here. |
| 500 / 502 | internal | Yes | The renderer failed. The run is not billed and the hold is released. Retry with the attempt counter bumped, or the idempotency replay will hand you the same dead job back. |
A clean estimate is not proof that a model runs.
/estimate prices a request; it does not execute one. It will happily return a
hold for a body that /run then rejects, and it says nothing about whether the
renderer is healthy, whether the model is currently reachable, or whether this particular
brief will come back refused. Treat it as a budgeting call and nothing more. The first honest
signal that the pipeline works end to end is a job that reaches succeeded with
bytes in images[0].b64, so make that your smoke test, not an estimate.
Two failure modes deserve separate handling. A transport failure —
a dropped connection, a proxy timeout — leaves you not knowing whether the run started.
That is exactly what the idempotency key is for: repeat the request with the same key
and you attach to the original job rather than paying twice. A job failure
— status: "failed" — is a decision the renderer already made, so
repeating it with the same key replays the failure. Bump the attempt counter for that one.
SF_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# Retry only rate_limited and internal. Everything else is a bug in the request.
ATTEMPT=1
while [ "$ATTEMPT" -le 4 ]; do
DIGEST=$(jq -r '.instruction' body.json | shasum -a 256 | cut -c1-16)
RES=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $DIGEST:attempt-$ATTEMPT" \
--data-binary @body.json)
if [ "$(printf '%s' "$RES" | jq -r '.ok')" = "true" ]; then
printf '%s' "$RES" | jq -r '.data.job_id'
break
fi
CODE=$(printf '%s' "$RES" | jq -r '.error.code')
case "$CODE" in
rate_limited|internal)
# Exponential back-off with jitter: 2s, 4s, 8s, plus 0-1s.
sleep $(( 2 ** ATTEMPT ))
ATTEMPT=$(( ATTEMPT + 1 ))
;;
*)
echo "not retryable: $CODE" >&2
printf '%s' "$RES" | jq '.error'
exit 1
;;
esac
done
import random
import time
RETRYABLE = {"rate_limited", "internal", "transport"}
def paint_with_retries(brief, tries=4):
"""Retry only what is worth retrying, bumping the attempt salt each time."""
for attempt in range(1, tries + 1):
try:
return paint(brief, attempt=attempt)
except AppError as err:
if err.code not in RETRYABLE or attempt == tries:
raise
# Exponential back-off with jitter.
time.sleep(min(2 ** attempt, 30) + random.random())
raise RuntimeError("unreachable")
# unauthorized, payment_required and validation_error are not in RETRYABLE:
# no amount of waiting turns a bad token or a malformed body into a picture.
paint_with_retries(BRIEF)
const RETRYABLE = new Set(["rate_limited", "internal", "transport"]);
/** Retries only what is worth retrying, bumping the attempt salt each time. */
async function paintWithRetries(brief, tries = 4) {
for (let attempt = 1; attempt <= tries; attempt++) {
try {
return await paint(brief, attempt);
} catch (err) {
if (!RETRYABLE.has(err.code) || attempt === tries) throw err;
// Exponential back-off with jitter.
await sleep(Math.min(2 ** attempt, 30) * 1000 + Math.random() * 1000);
}
}
throw new Error("unreachable");
}
// unauthorized, payment_required and validation_error are not retryable:
// no amount of waiting turns a bad token or a malformed body into a picture.
await paintWithRetries(BRIEF);
var retryable = map[string]bool{"rate_limited": true, "internal": true, "transport": true}
// paintWithRetries retries only what is worth retrying, bumping the attempt salt.
func paintWithRetries(brief, path string, tries int) error {
var last error
for attempt := 1; attempt <= tries; attempt++ {
last = paint(brief, path, attempt)
if last == nil {
return nil
}
var ae *apiError
if !errors.As(last, &ae) || !retryable[ae.Code] || attempt == tries {
return last
}
// Exponential back-off with jitter.
back := time.Duration(math.Min(math.Pow(2, float64(attempt)), 30)) * time.Second
time.Sleep(back + time.Duration(rand.Intn(1000))*time.Millisecond)
}
return last
}
// unauthorized, payment_required and validation_error are absent from the map:
// no amount of waiting turns a bad token or a malformed body into a picture.
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
static final Set<String> RETRYABLE = Set.of("rate_limited", "internal", "transport");
/** Retries only what is worth retrying, bumping the attempt salt each time. */
static Path paintWithRetries(String brief, Path out, int tries) throws Exception {
RuntimeException last = null;
for (int attempt = 1; attempt <= tries; attempt++) {
try {
return paint(brief, attempt, out);
} catch (SurrealImageGenerator.AppException e) {
last = e;
// Read error.code out of the envelope with Jackson or Gson.
String code = codeOf(e.getMessage());
if (!RETRYABLE.contains(code) || attempt == tries) throw e;
// Exponential back-off with jitter.
long back = Math.min(1L << attempt, 30L) * 1000L
+ ThreadLocalRandom.current().nextInt(1000);
Thread.sleep(back);
}
}
throw last;
}
// unauthorized, payment_required and validation_error are not in RETRYABLE:
// no amount of waiting turns a bad token or a malformed body into a picture.
RETRYABLE = %w[rate_limited internal transport].freeze
# Retries only what is worth retrying, bumping the attempt salt each time.
def paint_with_retries(brief, tries = 4)
(1..tries).each do |attempt|
return paint(brief, attempt)
rescue AppError => e
raise if !RETRYABLE.include?(e.code) || attempt == tries
# Exponential back-off with jitter.
sleep([2**attempt, 30].min + rand)
end
end
# unauthorized, payment_required and validation_error are not retryable:
# no amount of waiting turns a bad token or a malformed body into a picture.
paint_with_retries(BRIEF)
<?php
const RETRYABLE = ['rate_limited', 'internal', 'transport'];
/** Retries only what is worth retrying, bumping the attempt salt each time. */
function paint_with_retries(string $brief, int $tries = 4): string {
for ($attempt = 1; $attempt <= $tries; $attempt++) {
try {
return paint($brief, $attempt);
} catch (AppError $e) {
if (!in_array($e->code, RETRYABLE, true) || $attempt === $tries) {
throw $e;
}
// Exponential back-off with jitter.
usleep((int) ((min(2 ** $attempt, 30) + mt_rand(0, 1000) / 1000) * 1_000_000));
}
}
throw new RuntimeException('unreachable');
}
// unauthorized, payment_required and validation_error are not retryable:
// no amount of waiting turns a bad token or a malformed body into a picture.
paint_with_retries($BRIEF);
var retryable = new HashSet<string> { "rate_limited", "internal", "transport" };
var rng = new Random();
// Retries only what is worth retrying, bumping the attempt salt each time.
async Task<string> PaintWithRetries(string brief, int tries = 4)
{
for (var attempt = 1; attempt <= tries; attempt++)
{
try
{
return await Paint(brief, attempt);
}
catch (Exception e)
{
var code = e.Message.Split(':')[0];
if (!retryable.Contains(code) || attempt == tries) throw;
// Exponential back-off with jitter.
var back = Math.Min(Math.Pow(2, attempt), 30) * 1000 + rng.Next(1000);
await Task.Delay((int) back);
}
}
throw new InvalidOperationException("unreachable");
}
// unauthorized, payment_required and validation_error are not retryable:
// no amount of waiting turns a bad token or a malformed body into a picture.
await PaintWithRetries(Brief);
Before you ship
- The run body has two keys:
instructionand$model. Every other key gets painted into the picture. $modelis"gpt-image". Assert onmodel_aliasfrom/estimatethat it survived serialisation.- Signed in is
subject_type === "user"./mehas no email, no name and no id. - The picture is
output.images[0].b64, decoded and written as binary.output.outputis"". - The hold is a reservation, per picture, and it does not move with prompt length. Budget concurrency from
hold_credits; report spend fromcharged_credits. - Poll
/jobs/{id}every 1.5 s, give up at 240 s. There is no SSE worth using on an image run. - Idempotency keys carry a per-attempt salt, so a deliberate retry is a new run and an accidental duplicate is not.
- One operative break per brief; everything else ordinary, plus a named mundane anchor.
- The token lives in a secret store — never a repository, an image, a log line or a browser bundle.