525 lines
40 KiB
Python
525 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""Image services apart from the chat provider: fal.ai queue, Comet (Gemini route), a custom OpenAI-compatible service,
|
|
keys, prices, resume, SSRF, and the agent making an image with an Anthropic or OpenAI chat. All against loopback mocks."""
|
|
import base64
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from threading import Thread
|
|
from support import Client, Site, write_json
|
|
|
|
KEY_FAL = "fal-secret-key-123"
|
|
KEY_COMET = "comet-secret-key-456"
|
|
KEY_CUSTOM = "custom-secret-key-789"
|
|
KEY_CHAT = "sk-ant-chat-secret-000"
|
|
|
|
PNG = subprocess.check_output(["php", "-r", "$i=imagecreatetruecolor(64,48); imagefill($i,0,0,imagecolorallocate($i,30,120,200)); ob_start(); imagepng($i); echo ob_get_clean();"])
|
|
state = {"comet_status": "success", "list_fail": False, "list_reject_key": False, "status": "ok", "foreign": False, "private_result": False, "num": 1, "submits": 0, "gemini_thought": True, "custom_url": False}
|
|
log = [] # (method, path, headers, body)
|
|
internal_hits = []
|
|
chat_queue = []
|
|
chat_requests = []
|
|
|
|
|
|
def reply(handler, status, body, content_type="application/json"):
|
|
data = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
handler.send_response(status)
|
|
handler.send_header("Content-Type", content_type)
|
|
handler.send_header("Content-Length", str(len(data)))
|
|
handler.end_headers()
|
|
handler.wfile.write(data)
|
|
|
|
|
|
class Mock(BaseHTTPRequestHandler):
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def body(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
raw = self.rfile.read(length) if length else b""
|
|
try:
|
|
return json.loads(raw)
|
|
except ValueError:
|
|
return raw
|
|
|
|
def do_POST(self):
|
|
body = self.body()
|
|
log.append(("POST", self.path, dict(self.headers), body))
|
|
port = self.server.server_port
|
|
if self.path.startswith(("/fal-ai/", "/google/", "/openai/", "/bytedance/")) and "/requests/" not in self.path:
|
|
state["submits"] += 1
|
|
state["num"] = int(body.get("num_images", 1)) if isinstance(body, dict) else 1
|
|
status_url = f"http://127.0.0.1:{self.server.internal_port}/stolen/status" if state["foreign"] else f"http://127.0.0.1:{port}{self.path}/requests/req1/status"
|
|
return reply(self, 200, {"request_id": "req1", "status_url": status_url, "response_url": f"http://127.0.0.1:{port}{self.path}/requests/req1"})
|
|
if ":generateContent" in self.path:
|
|
parts = []
|
|
if state["gemini_thought"]:
|
|
parts.append({"thought": True, "inlineData": {"mimeType": "image/png", "data": base64.b64encode(b"not an image").decode()}})
|
|
parts.append({"text": "Here is your image."})
|
|
parts.append({"inlineData": {"mimeType": "image/png", "data": base64.b64encode(PNG).decode()}})
|
|
return reply(self, 200, {"candidates": [{"content": {"parts": parts}}]})
|
|
if self.path == "/v1/images/generations" and isinstance(body, dict) and body.get("async"):
|
|
return reply(self, 200, {"data": {"task_id": "t1", "status": "pending"}})
|
|
if self.path == "/v1/images/generations":
|
|
if state["custom_url"]:
|
|
return reply(self, 200, {"data": [{"url": f"http://127.0.0.1:{port}/img.png"}]})
|
|
return reply(self, 200, {"data": [{"b64_json": base64.b64encode(PNG).decode(), "revised_prompt": "a revised prompt"}]})
|
|
if self.path == "/chat/completions":
|
|
chat_requests.append(body)
|
|
answer = chat_queue.pop(0) if chat_queue else {"choices": [{"message": {"role": "assistant", "content": "unscripted"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}
|
|
return reply(self, 200, answer(body) if callable(answer) else answer)
|
|
reply(self, 404, {"error": {"message": "unknown route"}})
|
|
|
|
def do_GET(self):
|
|
log.append(("GET", self.path, dict(self.headers), None))
|
|
port = self.server.server_port
|
|
if self.path.startswith("/v1/models") or self.path == "/api/models":
|
|
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
if state["list_fail"] or (state["list_reject_key"] and "authorization" in headers):
|
|
return reply(self, 500 if state["list_fail"] else 401, {"error": "no"})
|
|
if self.path == "/api/models":
|
|
return reply(self, 200, {"success": True, "total": 3, "data": [{"id": "gpt-image-2", "model_type": "image"}, {"id": "gpt-5", "model_type": "chat"}, {"id": "bad id!", "model_type": "image"}]})
|
|
if "cursor=c2" in self.path:
|
|
return reply(self, 200, {"models": [{"endpoint_id": "fal-ai/second", "metadata": {"display_name": "Second <b>model</b>"}}, {"endpoint_id": "bad id!!"}], "has_more": False, "next_cursor": None})
|
|
return reply(self, 200, {"models": [{"endpoint_id": "fal-ai/nano-banana-2", "metadata": {"display_name": "Nano Banana 2"}}, {"endpoint_id": "fal-ai/flux/dev", "metadata": {"display_name": "FLUX.1 [dev]"}}], "has_more": True, "next_cursor": "c2"})
|
|
if self.path.startswith("/v1/images/generations/"):
|
|
if state["comet_status"] == "pending":
|
|
return reply(self, 200, {"data": {"task_id": "t1", "status": "pending"}})
|
|
if state["comet_status"] == "failure":
|
|
return reply(self, 200, {"data": {"status": "failure", "fail_reason": "blocked by policy"}})
|
|
return reply(self, 200, {"data": {"status": "success", "data": [{"url": f"http://cdn.fal.test:{port}/img.png"}]}})
|
|
if self.path.endswith("/status"):
|
|
mode = state["status"]
|
|
if mode == "progress":
|
|
return reply(self, 200, {"status": "IN_PROGRESS"})
|
|
if mode == "error":
|
|
return reply(self, 200, {"status": "COMPLETED", "error": "model refused the prompt", "error_type": "content_policy"})
|
|
return reply(self, 200, {"status": "COMPLETED"})
|
|
if "/requests/" in self.path:
|
|
host = f"cdn.fal.test:{port}"
|
|
if state["private_result"]:
|
|
host = f"127.0.0.1:{self.server.internal_port}"
|
|
return reply(self, 200, {"images": [{"url": f"http://{host}/img.png", "content_type": "image/png"} for _ in range(state["num"])]})
|
|
if self.path == "/img.png":
|
|
return reply(self, 200, PNG, "image/png")
|
|
reply(self, 404, {})
|
|
|
|
|
|
class Internal(BaseHTTPRequestHandler):
|
|
def log_message(self, *_):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
internal_hits.append(self.path)
|
|
reply(self, 200, PNG, "image/png")
|
|
|
|
|
|
def tool_calls(name, arguments, call_id):
|
|
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [{"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}}]}, "finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
|
|
|
|
|
|
def say(text):
|
|
return {"choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="raiju-image-service-") as folder:
|
|
mock = ThreadingHTTPServer(("127.0.0.1", 0), Mock)
|
|
internal = ThreadingHTTPServer(("127.0.0.1", 0), Internal)
|
|
mock.internal_port = internal.server_port
|
|
Thread(target=mock.serve_forever, daemon=True).start()
|
|
Thread(target=internal.serve_forever, daemon=True).start()
|
|
base = f"http://127.0.0.1:{mock.server_port}"
|
|
os.environ["PHP_CLI_SERVER_WORKERS"] = "8"
|
|
os.environ["RAIJU_AGENT_TEST_NET"] = json.dumps({
|
|
"imageBases": {"fal": base, "comet": base},
|
|
"imageListBases": {"fal": f"http://api.fal.test:{mock.server_port}", "comet": f"http://api.comet.test:{mock.server_port}"}, "imagePollMs": 10, "ports": [mock.server_port, internal.server_port],
|
|
"resolve": {"cdn.fal.test": ["93.184.216.34"], "api.fal.test": ["93.184.216.34"], "api.comet.test": ["93.184.216.34"]}, "connectMap": {"93.184.216.34": "127.0.0.1"}})
|
|
site = Site(folder)
|
|
root = site.root
|
|
write_json(root / "content/settings.json", {"modules": {"ai": True}, "privacy": {"analyticsEnabled": False}})
|
|
|
|
def php(code, check=True):
|
|
result = subprocess.run(["php", "-d", "display_errors=0", "-r", "require 'includes/ai/ai-helper.php'; " + code], cwd=root, text=True, capture_output=True)
|
|
if check:
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
return result.stdout
|
|
|
|
defaults = json.loads(php("echo json_encode(raijuAiDefaults());"))
|
|
|
|
def configure(chat="anthropic", image=None, keys=None, extra=None, limits=None, chat_base=None):
|
|
settings = json.loads(json.dumps(defaults))
|
|
settings.update(enabled=True, allowLocalProvider=True)
|
|
if chat == "anthropic":
|
|
settings.update(provider="anthropic", baseUrl="https://api.anthropic.com/v1", chatModel="claude-sonnet-5-5", textModel="claude-haiku-5-5")
|
|
settings["providerCredentials"]["anthropic"].update(baseUrl="https://api.anthropic.com/v1", apiKey=KEY_CHAT)
|
|
else:
|
|
settings.update(provider="openai-compatible", baseUrl=chat_base or base, chatModel="gpt-4.1-mini", apiKey="chat-key")
|
|
settings["providerCredentials"]["openai-compatible"].update(baseUrl=chat_base or base, apiKey="chat-key")
|
|
settings["limits"].update({"dailyRequests": 1000, "dailyTextRequests": 1000, "dailyImageRequests": 100, "monthlyBudgetCents": 100000, "requestTimeoutSeconds": 30, **(limits or {})})
|
|
settings["imageService"] = image or {"provider": "fal", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}
|
|
settings["imageServices"] = {k: {"apiKey": v} for k, v in (keys if keys is not None else {"fal": KEY_FAL}).items()}
|
|
settings["agent"] = {"enabled": True, "limits": {"runsPerHour": 100, "stepsPerMinute": 600}}
|
|
settings.update(extra or {})
|
|
write_json(root / "content/ai-settings.json", settings)
|
|
for name in ("content/ai-usage.json", "content/ai-secrets.json"):
|
|
(root / name).unlink(missing_ok=True)
|
|
|
|
def reservations():
|
|
return json.loads((root / "content/ai-usage.json").read_text())["reservations"]
|
|
|
|
def generate(prompt="A friendly robot", **options):
|
|
code = "$r = raijuAiGenerateImage(" + json.dumps(prompt) + ", json_decode(" + json.dumps(json.dumps(options)) + ", true)); echo json_encode($r);"
|
|
return json.loads(php(code))
|
|
|
|
def last(kind="POST", contains=""):
|
|
return [entry for entry in log if entry[0] == kind and contains in entry[1]][-1]
|
|
|
|
def fails(code):
|
|
result = subprocess.run(["php", "-d", "display_errors=0", "-r", "require 'includes/ai/ai-helper.php'; try { " + code + " echo 'NO-ERROR'; } catch (Throwable $e) { echo 'ERROR:' . $e->getMessage(); }"], cwd=root, text=True, capture_output=True)
|
|
assert result.stdout.startswith("ERROR:"), result.stdout + result.stderr
|
|
return result.stdout[6:]
|
|
|
|
try:
|
|
# ---- fal.ai with an Anthropic chat: the case that was impossible before -------------------------------------------------
|
|
configure()
|
|
status = json.loads(php("echo json_encode(raijuAiImageServicePublic(raijuAiLoadSettings(false))['active']);"))
|
|
assert status["usable"] and status["id"] == "fal" and status["keySource"] == "own" and status["model"] == "google/nano-banana-2.1", status
|
|
result = generate(aspectRatio="16:9", size="1536x1024", outputFormat="webp", imageScale=2048)
|
|
assert len(result["paths"]) == 1 and (root / result["paths"][0].lstrip("/")).is_file(), result
|
|
head = (root / result["paths"][0].lstrip("/")).read_bytes()
|
|
assert head[8:12] == b"WEBP", "stored in the requested format"
|
|
submit = last("POST", "/google/nano-banana-2.1")
|
|
assert submit[2].get("Authorization") == "Key " + KEY_FAL, "fal takes 'Key <key>'"
|
|
body = submit[3]
|
|
assert body["aspect_ratio"] == "16:9" and body["output_format"] == "webp" and body["resolution"] == "2K" and "num_images" not in body and body["prompt"] == "A friendly robot", body
|
|
assert last("GET", "/status")[2].get("Authorization") == "Key " + KEY_FAL
|
|
meta = json.loads((root / "content/media-meta.json").read_text())["image"]
|
|
assert any(v.get("ai") == "generated" for v in meta.values()), "the AI label is stored with the image: %s" % meta
|
|
reservation = list(reservations().values())[-1]
|
|
assert reservation["model"] == "google/nano-banana-2.1" and reservation["service"] == "fal" and reservation["provider"] == "fal" and reservation["status"] == "settled", reservation
|
|
assert reservation["reservedCents"] == 5.0 and reservation["priced"] is True, "the default price per image applies when nothing else is on file: %s" % reservation
|
|
|
|
# two images in one task; aspect ratios the model does not know go to the nearest one
|
|
before = state["submits"]
|
|
result = generate(count=2, aspectRatio="4:3", outputFormat="png")
|
|
assert len(result["paths"]) == 2 and state["submits"] == before + 1, "one request for two images (num_images)"
|
|
body = last("POST", "/google/nano-banana-2.1")[3]
|
|
assert body["num_images"] == 2 and body["aspect_ratio"] == "4:3" and body["output_format"] == "png", body
|
|
assert list(reservations().values())[-1]["reservedCents"] == 10.0
|
|
generate(aspectRatio="auto", size="1024x1536")
|
|
assert last("POST", "/google/nano-banana-2.1")[3]["aspect_ratio"] == "2:3", "a pixel size becomes its ratio"
|
|
|
|
# a model with image_size presets, a quality and custom parameters
|
|
configure(image={"provider": "fal", "model": "openai/gpt-image-2.5/flare/text-to-image", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {"enable_safety_checker": False}}})
|
|
generate(aspectRatio="16:9", outputFormat="webp")
|
|
body = last("POST", "/openai/gpt-image-2.5/flare/text-to-image")[3]
|
|
assert body["image_size"] == "landscape_16_9" and body["output_format"] == "webp" and body["quality"] == "auto" and "aspect_ratio" not in body, body
|
|
assert body["enable_safety_checker"] is False, "custom parameters are added to the request"
|
|
info = json.loads(php("echo json_encode(raijuAiImageServicePublic(raijuAiLoadSettings(false))['active']);"))
|
|
assert info["verified"] == "2026-10-17" and info["note"], "the date of the check is reported: %s" % info
|
|
# an unknown model gets the defaults of the service, also marked
|
|
configure(image={"provider": "fal", "model": "fal-ai/brand-new-model", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}})
|
|
generate(aspectRatio="3:2")
|
|
body = last("POST", "/fal-ai/brand-new-model")[3]
|
|
assert body["aspect_ratio"] == "3:2" and set(body) == {"prompt", "aspect_ratio"}, body
|
|
|
|
# reference images need an edit endpoint
|
|
configure()
|
|
ref = root / "assets/images/placeholder-image.webp"
|
|
result = generate(referenceMediaPaths=["/assets/images/placeholder-image.webp"])
|
|
edit = last("POST", "/google/nano-banana-2.1/edit")[3]
|
|
assert edit["image_urls"][0].startswith("data:image/") and result["paths"], "reference image sent to the edit endpoint"
|
|
configure(image={"provider": "fal", "model": "fal-ai/brand-new-model", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}})
|
|
assert "reference" in fails("raijuAiGenerateImage('x', ['referenceMediaPaths' => ['/assets/images/placeholder-image.webp']]);").lower()
|
|
|
|
# ---- failures -----------------------------------------------------------------------------------------------------------
|
|
configure()
|
|
state["status"] = "error"
|
|
message = fails("raijuAiGenerateImage('x');")
|
|
assert "model refused the prompt" in message and KEY_FAL not in message, message
|
|
state["status"] = "ok"
|
|
state["private_result"] = True
|
|
internal_before = len(internal_hits)
|
|
message = fails("raijuAiGenerateImage('x');")
|
|
assert "not allowed" in message and len(internal_hits) == internal_before, "a result address on a private host is never fetched: " + message
|
|
state["private_result"] = False
|
|
state["foreign"] = True
|
|
generate()
|
|
assert not internal_hits, "a status address on another host is not followed (the key would go with it): %s" % internal_hits
|
|
state["foreign"] = False
|
|
|
|
# ---- resume: the task stays in the ledger and is not submitted again ---------------------------------------------------------
|
|
configure(limits={"requestTimeoutSeconds": 5})
|
|
state["status"] = "progress"
|
|
before = state["submits"]
|
|
message = fails("raijuAiGenerateImage('x', ['_requestId' => 'image_job_0123456789abcdef']);")
|
|
assert "did not finish" in message, message
|
|
task = reservations()["image_job_0123456789abcdef"]["tasks"][0]
|
|
assert task["id"] == "req1" and task["state"] == "running" and state["submits"] == before + 1, task
|
|
state["status"] = "ok"
|
|
result = json.loads(php("echo json_encode(raijuAiGenerateImage('x', ['_requestId' => 'image_job_0123456789abcdef']));"))
|
|
assert state["submits"] == before + 1 and len(result["paths"]) == 1, "resumed without a second submission"
|
|
assert reservations()["image_job_0123456789abcdef"]["status"] == "settled"
|
|
assert "urls" not in reservations()["image_job_0123456789abcdef"]["tasks"][0] and reservations()["image_job_0123456789abcdef"]["outputs"], "result addresses are dropped from the ledger once the images are stored"
|
|
|
|
# ---- Comet: Gemini route ------------------------------------------------------------------------------------------------------
|
|
configure(image={"provider": "comet", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {"safetySettings": [{"category": "x"}]}}}, keys={"comet": KEY_COMET})
|
|
result = generate(aspectRatio="16:9", imageScale=3840, outputFormat="webp")
|
|
entry = last("POST", ":generateContent")
|
|
assert entry[1] == "/v1beta/models/gemini-nano-banana-2.1:generateContent" and entry[2].get("Authorization") == "Bearer " + KEY_COMET, entry[:2]
|
|
gen = entry[3]["generationConfig"]
|
|
assert gen["imageConfig"] == {"aspectRatio": "16:9", "imageSize": "4K"} and gen["responseModalities"] == ["TEXT", "IMAGE"] and entry[3]["safetySettings"], entry[3]
|
|
assert (root / result["paths"][0].lstrip("/")).read_bytes()[8:12] == b"WEBP", "the thought part is skipped, the real image stored"
|
|
result = generate(referenceMediaPaths=["/assets/images/placeholder-image.webp"])
|
|
parts = last("POST", ":generateContent")[3]["contents"][0]["parts"]
|
|
assert parts[1]["inline_data"]["mime_type"] == "image/webp" and parts[1]["inline_data"]["data"] and not parts[1]["inline_data"]["data"].startswith("data:"), "raw base64, no data: prefix"
|
|
|
|
# Comet: GPT Image 2.5 over the OpenAI images route (below /v1), one image per request
|
|
gpt = {"provider": "comet", "model": "gpt-image-2.5-flare", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}
|
|
configure(image=gpt, keys={"comet": KEY_COMET})
|
|
generate(aspectRatio="16:9", outputFormat="webp", quality="high")
|
|
entry = last("POST", "/v1/images/generations")
|
|
assert entry[2].get("Authorization") == "Bearer " + KEY_COMET and entry[3]["model"] == "gpt-image-2.5-flare", entry[:2]
|
|
assert entry[3]["size"] == "1536x1024" and entry[3]["quality"] == "high" and entry[3]["output_format"] == "webp" and entry[3]["n"] == 1 and "moderation" not in entry[3], entry[3]
|
|
posts_before = len([e for e in log if e[0] == "POST" and e[1] == "/v1/images/generations"])
|
|
result = generate(count=2)
|
|
assert len(result["paths"]) == 2 and len([e for e in log if e[0] == "POST" and e[1] == "/v1/images/generations"]) == posts_before + 2, "n stays 1: one request per image"
|
|
# Comet: Seedream 5.0 Pro over the asynchronous route
|
|
seed = dict(gpt, model="seedream-5-0-pro-260628")
|
|
configure(image=seed, keys={"comet": KEY_COMET})
|
|
generate(imageScale=2048)
|
|
create = [e for e in log if e[0] == "POST" and e[1] == "/v1/images/generations" and isinstance(e[3], dict) and e[3].get("async")][-1]
|
|
assert create[3]["model"] == "seedream-5-0-pro-260628" and create[3]["size"] == "2K" and create[3]["response_format"] == "url" and create[3]["watermark"] is False, create[3]
|
|
assert last("GET", "/v1/images/generations/t1")[2].get("Authorization") == "Bearer " + KEY_COMET
|
|
state["comet_status"] = "failure"
|
|
message = fails("raijuAiGenerateImage('x');")
|
|
assert "blocked by policy" in message, message
|
|
state["comet_status"] = "success"
|
|
assert "reference" in fails("raijuAiGenerateImage('x', ['referenceMediaPaths' => ['/assets/images/placeholder-image.webp']]);").lower(), "Comet Seedream takes no references"
|
|
|
|
# ---- custom OpenAI-compatible service ----------------------------------------------------------------------------------------------
|
|
custom = {"provider": "custom", "model": "my-image-model", "models": {}, "custom": {"baseUrl": base + "/v1", "sizeMode": "size", "authStyle": "bearer", "extraParams": {"style": "natural"}}}
|
|
configure(image=custom, keys={"custom": KEY_CUSTOM})
|
|
result = generate(aspectRatio="16:9")
|
|
entry = last("POST", "/v1/images/generations")
|
|
assert entry[2].get("Authorization") == "Bearer " + KEY_CUSTOM and entry[3]["model"] == "my-image-model" and entry[3]["size"] == "1536x1024" and entry[3]["style"] == "natural" and "output_format" not in entry[3], entry[3]
|
|
custom["custom"].update(authStyle="x-api-key", sizeMode="aspect_ratio")
|
|
configure(image=custom, keys={"custom": KEY_CUSTOM})
|
|
state["custom_url"] = True
|
|
generate(aspectRatio="16:9")
|
|
entry = last("POST", "/v1/images/generations")
|
|
headers = {k.lower(): v for k, v in entry[2].items()}
|
|
assert headers.get("x-api-key") == KEY_CUSTOM and "authorization" not in headers and entry[3]["aspect_ratio"] == "16:9" and "size" not in entry[3], entry
|
|
state["custom_url"] = False
|
|
|
|
# ---- keys: stored apart, masked, removed ---------------------------------------------------------------------------------------------
|
|
configure(keys={})
|
|
save = {"enabled": True, "provider": "anthropic", "allowLocalProvider": True, "apiKey": KEY_CHAT, "imageService": {"provider": "fal", "model": "google/nano-banana-2.1"},
|
|
"imageServices": {"fal": {"apiKey": KEY_FAL}}, "features": {"imageGeneration": True}}
|
|
php("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(save)) + ", true));")
|
|
secrets = json.loads((root / "content/ai-secrets.json").read_text())
|
|
assert secrets["imageServices"]["fal"]["apiKey"] == KEY_FAL and KEY_FAL not in (root / "content/ai-settings.json").read_text(), "the key is in the secrets file only"
|
|
masked = dict(save, imageServices={"fal": {"apiKey": "••••••••"}})
|
|
php("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(masked)) + ", true));")
|
|
assert json.loads((root / "content/ai-secrets.json").read_text())["imageServices"]["fal"]["apiKey"] == KEY_FAL, "a mask never replaces the key"
|
|
blank = dict(save, imageServices={})
|
|
public = php("echo json_encode(raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(blank)) + ", true)));")
|
|
assert KEY_FAL not in public and KEY_CHAT not in public, "no key in the settings that go to the browser"
|
|
assert json.loads(public)["imageServiceStatus"]["services"]["fal"] == {"hasApiKey": True, "chatKeyAvailable": False, "keySource": "own"}
|
|
# prices per model
|
|
priced = dict(save, pricing={"imageModels": {"fal|google/nano-banana-2.1": {"perImage": 0.04, "currency": "USD"}, "bad key": {"perImage": 1}}})
|
|
php("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(priced)) + ", true));")
|
|
stored = json.loads((root / "content/ai-settings.json").read_text())["pricing"]["imageModels"]
|
|
assert stored == {"fal|google/nano-banana-2.1": {"perImage": 0.04, "currency": "USD"}}, stored
|
|
generate()
|
|
reservation = list(reservations().values())[-1]
|
|
assert 3 < reservation["reservedCents"] < 4 and reservation["priced"] is True, "USD price converted to euro cents: %s" % reservation
|
|
assert json.loads((root / "content/ai-usage.json").read_text())["reservations"][reservation["id"]]["priceBasis"]["source"] == "override"
|
|
# no price at all: shown as unknown, not as free
|
|
none = dict(save, pricing={"imageCentsPerRequest": 0, "imageModels": {"fal|google/nano-banana-2.1": None}})
|
|
php("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(none)) + ", true));")
|
|
generate()
|
|
reservation = list(reservations().values())[-1]
|
|
month = list(json.loads((root / "content/ai-usage.json").read_text())["months"].values())[-1]
|
|
assert reservation["priced"] is False and reservation["reservedCents"] == 0 and month["unpricedImageRequests"] >= 1, (reservation, month)
|
|
# every file the run wrote, except the secrets file, is free of keys
|
|
leaks = [str(p.relative_to(root)) for p in root.rglob("*") if p.is_file() and p.suffix in (".json", ".jsonl") and p.name != "ai-secrets.json" and any(k.encode() in p.read_bytes() for k in (KEY_FAL, KEY_COMET, KEY_CUSTOM, KEY_CHAT))]
|
|
assert not leaks, leaks
|
|
removed = php("echo json_encode(raijuAiClearSecret('image:fal'));")
|
|
assert removed.strip() == "true" and "imageServices" not in json.loads((root / "content/ai-secrets.json").read_text()), "the key is removed, the chat key stays"
|
|
# (the mock address is local, which would work without a key: switch that permission off for this check)
|
|
stored_settings = json.loads((root / "content/ai-settings.json").read_text())
|
|
write_json(root / "content/ai-settings.json", dict(stored_settings, allowLocalProvider=False))
|
|
reason = php("echo raijuAiImageUnavailableReason(raijuAiLoadSettings(false));")
|
|
assert reason == "key_missing", reason
|
|
write_json(root / "content/ai-settings.json", stored_settings)
|
|
assert "Unknown image service" in fails("raijuAiClearSecret('image:nope');"), "unknown targets are refused"
|
|
|
|
# ---- a stored address of an unused custom service does not block saving --------------------------------------------------------------
|
|
configure(image={"provider": "fal", "model": "", "models": {}, "custom": {"baseUrl": "http://127.0.0.1:9/v1", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}})
|
|
stale = {"enabled": True, "provider": "anthropic", "allowLocalProvider": False, "imageService": {"provider": "fal"}, "features": {"imageGeneration": True}}
|
|
php("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(stale)) + ", true));")
|
|
refused = fails("raijuAiSaveSettings(json_decode(" + json.dumps(json.dumps(dict(stale, imageService={"provider": "custom"}))) + ", true));")
|
|
assert "explicitly allowed" in refused, "choosing the custom service checks its address: " + refused
|
|
|
|
# ---- key of the chat provider of the same name -------------------------------------------------------------------------------------
|
|
configure(chat="openai", image={"provider": "openai-compatible", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}, keys={})
|
|
status = json.loads(php("echo json_encode(raijuAiImageServicePublic(raijuAiLoadSettings(false)));"))
|
|
assert status["services"]["openai-compatible"] == {"hasApiKey": False, "chatKeyAvailable": False, "keySource": "none"} and status["active"]["usable"] is False, "a chat key for another host is never used for OpenAI: %s" % status["active"]
|
|
configure(chat="openai", chat_base="https://api.openai.com/v1", image={"provider": "openai-compatible", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}, keys={})
|
|
status = json.loads(php("echo json_encode(raijuAiImageServicePublic(raijuAiLoadSettings(false)));"))
|
|
assert status["services"]["openai-compatible"] == {"hasApiKey": False, "chatKeyAvailable": True, "keySource": "chat"} and status["active"]["usable"] is True and status["active"]["keySource"] == "chat", "the key of an OpenAI chat is used for OpenAI images: %s" % status["active"]
|
|
# the chat provider's own images (service "inherit") behave as before; Anthropic has none
|
|
configure(chat="openai", image={"provider": "inherit", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}, keys={})
|
|
assert php("echo json_encode(raijuAiImageServiceUsable(raijuAiLoadSettings(false)));") == "true"
|
|
configure(chat="anthropic", image={"provider": "inherit", "model": "", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}}, keys={})
|
|
assert php("echo json_encode(raijuAiImageServiceUsable(raijuAiLoadSettings(false)));") == "false"
|
|
assert "no image API" in fails("raijuAiGenerateImage('x');")
|
|
# Anthropic chat, no image service set up: the agent has no image tool; with fal it does
|
|
tools = "echo json_encode(array_column(raijuAgentToolsForUser(), 'name'));"
|
|
prefix = "define('SITE_LANG_DEFAULT','en'); $_SESSION=['admin_role'=>'admin','admin_username'=>'admin']; require 'includes/ai/agent/run.php'; "
|
|
names = json.loads(subprocess.check_output(["php", "-d", "display_errors=0", "-r", prefix + tools], cwd=root, text=True))
|
|
assert "generate_image" not in names, names
|
|
configure()
|
|
names = json.loads(subprocess.check_output(["php", "-d", "display_errors=0", "-r", prefix + tools], cwd=root, text=True))
|
|
assert "generate_image" in names, "an Anthropic chat with a fal.ai image service gives the agent its image tool"
|
|
|
|
# ---- price by resolution and quality --------------------------------------------------------------------------------------------
|
|
configure(extra={"pricing": {"imageCentsPerRequest": 5, "usdPerEur": 0, "imageModels": {"fal|google/nano-banana-2.1": {"currency": "EUR", "byResolution": {"1K": 0.02, "4K": 0.2}}}, "models": {}}})
|
|
generate(imageScale=3840)
|
|
reservation = list(reservations().values())[-1]
|
|
assert reservation["reservedCents"] == 20.0 and reservation["priceBasis"]["basis"] == "resolution:4K" and reservation["priceBasis"]["source"] == "override", reservation
|
|
generate(imageScale=1024, count=2)
|
|
reservation = list(reservations().values())[-1]
|
|
assert reservation["reservedCents"] == 4.0 and reservation["priceBasis"]["count"] == 2, reservation
|
|
generate(imageScale=2048)
|
|
reservation = list(reservations().values())[-1]
|
|
assert reservation["reservedCents"] == 5.0 and reservation["priceBasis"]["source"] == "default", "2K has no price: the default price per image applies: %s" % reservation
|
|
|
|
# ---- live model lists ----------------------------------------------------------------------------------------------------------
|
|
configure()
|
|
list_log = lambda: [e for e in log if e[0] == "GET" and (e[1].startswith("/v1/models") or e[1] == "/api/models")]
|
|
(root / "content/ai-image-models-cache.json").unlink(missing_ok=True)
|
|
listed = json.loads(php("echo json_encode(raijuAiImageLiveModels(raijuAiLoadSettings(false), 'fal'));"))
|
|
assert [m["id"] for m in listed["models"]] == ["fal-ai/flux/dev", "fal-ai/nano-banana-2", "fal-ai/second"] and not listed["cached"], listed
|
|
assert listed["models"][2]["label"] == "fal.ai: Second model", "tags are stripped from names"
|
|
requests_made = list_log()
|
|
assert len(requests_made) == 2 and "category=text-to-image" in requests_made[0][1] and "status=active" in requests_made[0][1] and "cursor=c2" in requests_made[1][1], [e[1] for e in requests_made]
|
|
assert {k.lower(): v for k, v in requests_made[0][2].items()}.get("authorization") == "Key " + KEY_FAL, "the saved key raises the rate limit"
|
|
again = json.loads(php("echo json_encode(raijuAiImageLiveModels(raijuAiLoadSettings(false), 'fal'));"))
|
|
assert again["cached"] and len(list_log()) == 2, "a day of cache: no new request"
|
|
assert KEY_FAL not in (root / "content/ai-image-models-cache.json").read_text(), "the cache holds no key"
|
|
state["list_fail"] = True
|
|
stale = json.loads(php("echo json_encode(raijuAiImageLiveModels(raijuAiLoadSettings(false), 'fal', true));"))
|
|
assert stale["cached"] and len(stale["models"]) == 3, "a service that does not answer: the cached list is served"
|
|
(root / "content/ai-image-models-cache.json").unlink()
|
|
assert "could not be loaded" in fails("raijuAiImageLiveModels(raijuAiLoadSettings(false), 'fal');"), "no cache, no answer: an error"
|
|
state["list_fail"] = False
|
|
state["list_reject_key"] = True
|
|
before = len(list_log())
|
|
listed = json.loads(php("echo json_encode(raijuAiImageLiveModels(raijuAiLoadSettings(false), 'fal', true));"))
|
|
assert len(listed["models"]) == 3 and len(list_log()) > before + 1, "a refused key does not take the list away (retried without it)"
|
|
state["list_reject_key"] = False
|
|
comet = json.loads(php("echo json_encode(raijuAiImageLiveModels(raijuAiLoadSettings(false), 'comet'));"))
|
|
assert [m["id"] for m in comet["models"]] == ["gpt-image-2"], "only image models with a valid id: %s" % comet
|
|
assert "no model list" in fails("raijuAiImageLiveModels(raijuAiLoadSettings(false), 'kie');")
|
|
|
|
# ---- the agent over HTTP: OpenAI chat, fal.ai images ------------------------------------------------------------------------------------
|
|
configure(chat="openai")
|
|
site.start()
|
|
owner = Client(site).login("admin")
|
|
|
|
def api(action, **fields):
|
|
return owner.api(action, **fields)
|
|
|
|
chat_queue[:] = [tool_calls("generate_image", {"prompt": "A friendly robot waving", "aspect": "16:9"}, "c1")]
|
|
|
|
def stage(body):
|
|
refs = [json.loads(m["content"]).get("mediaRef") for m in body["messages"] if m["role"] == "tool" and m["content"].startswith("{")]
|
|
ref = next(r for r in refs if r)
|
|
return tool_calls("stage_create_page", {"lang": "en", "slug": "robot-page", "title": "Robots", "sections": [{"type": "image", "fields": {"src": ref, "alt": "A friendly robot waving"}}]}, "c2")
|
|
|
|
chat_queue.extend([stage, tool_calls("validate_changeset", {}, "c3"), tool_calls("present_changeset", {"summary": "A robot page."}, "c4")])
|
|
submits = state["submits"]
|
|
status, payload = api("ai-agent-start", goal="Make a robot page with a generated image")
|
|
assert payload["success"], payload
|
|
run_id = payload["data"]["runId"]
|
|
states = []
|
|
for _ in range(60):
|
|
status, payload = api("ai-agent-step", runId=run_id)
|
|
assert payload["success"], payload
|
|
states.append(payload["data"]["status"])
|
|
if payload["data"]["status"] not in ("running", "busy", "waiting_jobs"):
|
|
break
|
|
assert payload["data"]["status"] == "awaiting_review", states
|
|
assert state["submits"] == submits + 1 and last("POST", "/google/nano-banana-2.1")[3]["aspect_ratio"] == "16:9", "the agent's image came from the image service"
|
|
changeset = next((root / "content/ai-agent/changesets").glob("*.json"))
|
|
media = json.loads(changeset.read_text())["media"]
|
|
assert [m["source"] for m in media.values()] == ["generated"] and next(iter(media.values()))["ai"] == "generated", media
|
|
assert list((root / "content/ai-agent/staging").rglob("*.webp")), "the generated image waits in the staging folder"
|
|
assert KEY_FAL.encode() not in b"".join(p.read_bytes() for p in (root / "content/ai-image-jobs").glob("*.json")), "no key in the job files"
|
|
assert KEY_FAL not in json.dumps(chat_requests), "no key reaches the model"
|
|
assert "references" in [t["function"]["parameters"]["properties"] for t in chat_requests[0]["tools"] if t["function"]["name"] == "generate_image"][0], "the tool offers references"
|
|
|
|
# the model list over HTTP: admins only
|
|
status, payload = api("ai-image-models", service="comet")
|
|
assert payload["success"] and [m["id"] for m in payload["data"]["models"]] == ["gpt-image-2"], payload
|
|
assert api("ai-image-models", service="kie")[1]["success"] is False
|
|
editor = Client(site).login("editor")
|
|
assert editor.api("ai-image-models", service="comet")[1]["success"] is False, "editors cannot reach it"
|
|
|
|
# reference images: a mediaRef of this run and a library path go to the edit endpoint; the result is "AI-modified"
|
|
def first_ref(body):
|
|
return next(json.loads(m["content"])["mediaRef"] for m in body["messages"] if m["role"] == "tool" and m["content"].startswith("{") and "mediaRef" in m["content"])
|
|
|
|
edits_before = len([e for e in log if e[0] == "POST" and e[1].endswith("/edit")])
|
|
chat_requests.clear()
|
|
chat_queue[:] = [
|
|
tool_calls("generate_image", {"prompt": "A robot", "aspect": "1:1"}, "g1"),
|
|
lambda body: tool_calls("generate_image", {"prompt": "The same robot in watercolor", "aspect": "4:3", "references": [first_ref(body), "/assets/images/placeholder-image.webp"]}, "g2"),
|
|
tool_calls("present_changeset", {"summary": "Two images."}, "g3"),
|
|
]
|
|
status, payload = api("ai-agent-start", goal="Make a robot and then a watercolor version of it")
|
|
assert payload["success"], payload
|
|
run_two = payload["data"]["runId"]
|
|
for _ in range(80):
|
|
status, payload = api("ai-agent-step", runId=run_two)
|
|
assert payload["success"], payload
|
|
if payload["data"]["status"] not in ("running", "busy", "waiting_jobs"):
|
|
break
|
|
edit_calls = [e for e in log if e[0] == "POST" and e[1].endswith("/edit")]
|
|
assert len(edit_calls) == edits_before + 1, "the second image went to the edit endpoint"
|
|
urls = edit_calls[-1][3]["image_urls"]
|
|
assert len(urls) == 2 and all(u.startswith("data:image/") for u in urls), "the staged image of the run and the library image are both sent"
|
|
assert edit_calls[-1][3]["aspect_ratio"] == "4:3", edit_calls[-1][3]
|
|
changeset = sorted((root / "content/ai-agent/changesets").glob("*.json"), key=lambda f: f.stat().st_mtime)[-1]
|
|
media = json.loads(changeset.read_text())["media"]
|
|
assert sorted(m["ai"] for m in media.values()) == ["generated", "modified"], media
|
|
|
|
# wrong references fail the call, not the job; a service without an edit endpoint says so
|
|
chat_queue[:] = [tool_calls("generate_image", {"prompt": "x", "references": ["m_00000000"]}, "e1"), say("ok")]
|
|
status, payload = api("ai-agent-start", goal="Reference that does not exist")
|
|
run_three = payload["data"]["runId"]
|
|
for _ in range(20):
|
|
status, payload = api("ai-agent-step", runId=run_three)
|
|
if payload["data"]["status"] not in ("running", "busy", "waiting_jobs"):
|
|
break
|
|
assert "Unknown reference image" in json.dumps(chat_requests[-1]["messages"]), "the tool error reaches the model"
|
|
configure(chat="openai", image={"provider": "fal", "model": "fal-ai/brand-new-model", "models": {}, "custom": {"baseUrl": "", "sizeMode": "size", "authStyle": "bearer", "extraParams": {}}})
|
|
chat_queue[:] = [tool_calls("generate_image", {"prompt": "x", "references": ["/assets/images/placeholder-image.webp"]}, "e2"), say("ok")]
|
|
status, payload = api("ai-agent-start", goal="Reference with a service that cannot")
|
|
run_four = payload["data"]["runId"]
|
|
for _ in range(20):
|
|
status, payload = api("ai-agent-step", runId=run_four)
|
|
if payload["data"]["status"] not in ("running", "busy", "waiting_jobs"):
|
|
break
|
|
assert "cannot use reference images" in json.dumps(chat_requests[-1]["messages"]), "an image service without references says so"
|
|
finally:
|
|
mock.shutdown()
|
|
internal.shutdown()
|
|
if site.process:
|
|
site.close()
|
|
print("PASS image services: fal.ai queue (aspect, count, presets, references, resume, errors), Comet Gemini route, custom OpenAI-compatible, keys, prices, SSRF, Anthropic chat with an image service, agent image")
|