Two endpoints, two shapes
One app whose endpoints take request shapes with nothing in common, so no single schema covers both.
Introduction
generate takes a prompt and upscale takes an image, so no single request schema covers both. Each handler's signature becomes its own, and the platform routes by path.
What they do share is the app: one queue, one worker pool, one GPU type. That is set on the app rather than in the file, which is why nothing here names hardware or scaling. Two endpoints that genuinely need different GPUs are two apps.
The app
"""Two endpoints on one app, with request shapes that have nothing in common."""
from __future__ import annotations
from runware_serverless import endpoint, serve
PIXEL_PNG = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
)
"""A 1x1 white PNG, base64. Stands in for generated pixels."""
BASE_SIZE = 64
DEFAULT_STEPS = 4
DEFAULT_SCALE = 2
@serve
class ImageTools:
def load(self) -> None:
pass
@endpoint
def generate(self, prompt: str, steps: int = DEFAULT_STEPS) -> dict[str, object]:
# An omitted optional field arrives as an explicit null, so the default
# is applied here as well as in the signature.
steps = DEFAULT_STEPS if steps is None else steps
return {
"handler": "generate",
"prompt": prompt,
"steps": steps,
"width": BASE_SIZE,
"height": BASE_SIZE,
"image": PIXEL_PNG,
}
@endpoint
def upscale(self, image: str, scale: int = DEFAULT_SCALE) -> dict[str, object]:
scale = DEFAULT_SCALE if scale is None else scale
return {
"handler": "upscale",
"scale": scale,
"width": BASE_SIZE * scale,
"height": BASE_SIZE * scale,
"image": image,
}Deploying it
runware serverless deploy model.py --id image-tools --gpu-type l40sInvoking it
Both endpoints are on the same app, so only the last segment of the path changes.
import { createClient } from '@runware/sdk'
const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })
const task = await client.invoke({
appId: 'image-tools',
endpointPath: 'generate',
payload: {
prompt: 'a red bicycle'
}
})import asyncio
import os
from runware import Runware
async def main():
client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
try:
task = await client.invoke(
"image-tools",
"generate",
{
"prompt": "a red bicycle"
},
)
finally:
await client.close()
asyncio.run(main())curl https://api.serverless.runware.ai/v1/apps/image-tools/invoke-async/generate \
-H "Authorization: Bearer $RUNWARE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"prompt": "a red bicycle"
}
}'echo '{"prompt":"a red bicycle"}' | runware serverless apps invoke image-tools generate -f -{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"prompt": "a red bicycle"
}
}upscale takes an image instead, and nothing in its body carries over from the call above.
import { createClient } from '@runware/sdk'
const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })
const task = await client.invoke({
appId: 'image-tools',
endpointPath: 'upscale',
payload: {
image: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=',
scale: 2
}
})import asyncio
import os
from runware import Runware
async def main():
client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
try:
task = await client.invoke(
"image-tools",
"upscale",
{
"image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=",
"scale": 2
},
)
finally:
await client.close()
asyncio.run(main())curl https://api.serverless.runware.ai/v1/apps/image-tools/invoke-async/upscale \
-H "Authorization: Bearer $RUNWARE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=",
"scale": 2
}
}'cat <<'EOF' | runware serverless apps invoke image-tools upscale -f -
{
"image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=",
"scale": 2
}
EOF{
"taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payload": {
"image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=",
"scale": 2
}
}steps and scale both have defaults in their signature, so you can leave them out. An omitted optional field arrives as an explicit null rather than as a missing key, which is why each handler applies its default a second time.
Testing it before you deploy
"""Each endpoint is a method, so each test is a call."""
from model import BASE_SIZE, PIXEL_PNG, ImageTools
def test_generate_reports_its_own_handler() -> None:
model = ImageTools()
model.load()
result = model.generate("a red bicycle")
assert result["handler"] == "generate"
assert result["steps"] == 4
assert result["image"] == PIXEL_PNG
def test_upscale_takes_a_different_shape_entirely() -> None:
model = ImageTools()
model.load()
result = model.upscale(PIXEL_PNG, scale=4)
assert result["handler"] == "upscale"
assert result["width"] == BASE_SIZE * 4
def test_each_handler_applies_its_own_default() -> None:
model = ImageTools()
model.load()
assert model.generate("a red bicycle", steps=None)["steps"] == 4
assert model.upscale(PIXEL_PNG, scale=None)["scale"] == 2The whole example is at image-tools/, with a README of its own.