Spaces:
Running
Running
First commit
Browse files- .gitignore +1 -0
- src/app/server/actions/generateImage.ts +181 -67
- src/app/server/actions/generateStoryLines.ts +150 -56
- tests/test_connections/test_endpoints.mjs +113 -56
- tests/test_connections/test_endpoints.sh +83 -20
- tests/test_connections/test_story.sh +124 -0
.gitignore
CHANGED
|
@@ -37,3 +37,4 @@ next-env.d.ts
|
|
| 37 |
/sandbox/
|
| 38 |
.env
|
| 39 |
.env
|
|
|
|
|
|
| 37 |
/sandbox/
|
| 38 |
.env
|
| 39 |
.env
|
| 40 |
+
/backup
|
src/app/server/actions/generateImage.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getValidNumber } from "@/lib/getValidNumber";
|
|
| 6 |
|
| 7 |
const BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 8 |
const SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || "";
|
|
|
|
| 9 |
const DEBUG = (process.env.DEBUG_IMAGE_API || "").toLowerCase() === "true";
|
| 10 |
|
| 11 |
function assertEnv() {
|
|
@@ -13,9 +14,16 @@ function assertEnv() {
|
|
| 13 |
if (!SECRET) throw new Error("Missing AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN");
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
function abbrev(s: unknown, n = 1200) {
|
|
|
|
| 17 |
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 18 |
-
return t
|
| 19 |
}
|
| 20 |
|
| 21 |
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
@@ -24,122 +32,235 @@ async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
| 24 |
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)) as Promise<T>,
|
| 25 |
]);
|
| 26 |
}
|
|
|
|
| 27 |
async function timedFetch(url: string, init?: RequestInit) {
|
| 28 |
const t0 = Date.now();
|
| 29 |
-
const res = await withTimeout(fetch(url, init));
|
| 30 |
return { res, ms: Date.now() - t0 };
|
| 31 |
}
|
|
|
|
| 32 |
function stripToJson(text: string) {
|
| 33 |
if (!text) return "";
|
| 34 |
const iBrace = text.indexOf("{");
|
| 35 |
const iBrack = text.indexOf("[");
|
| 36 |
-
const
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
|
|
|
|
|
| 41 |
if (ct.includes("application/json")) {
|
| 42 |
const txt = await res.text();
|
| 43 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
|
|
|
| 45 |
const reader = res.body?.getReader();
|
| 46 |
if (!reader) return null;
|
|
|
|
| 47 |
const decoder = new TextDecoder();
|
| 48 |
let buf = "";
|
| 49 |
-
let
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^\s/, "");
|
| 65 |
-
if (DEBUG && line.trim()) console.log("[SSE]", line);
|
| 66 |
-
if (field === "data") dataLines.push(value);
|
| 67 |
}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
|
|
|
| 74 |
}
|
|
|
|
|
|
|
| 75 |
};
|
| 76 |
|
| 77 |
while (true) {
|
| 78 |
-
if (Date.now() -
|
| 79 |
const { value, done } = await reader.read();
|
| 80 |
if (done) break;
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
-
|
|
|
|
| 84 |
}
|
| 85 |
|
|
|
|
| 86 |
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 87 |
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
|
|
|
| 88 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 89 |
method: "POST",
|
| 90 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
body: JSON.stringify({ data: dataArray }),
|
| 92 |
cache: "no-store",
|
| 93 |
});
|
|
|
|
| 94 |
const postTxt = await postRes.text();
|
| 95 |
-
const postJson = (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if (!postRes.ok) {
|
| 97 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postTxt ?? "(empty body)";
|
| 98 |
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 99 |
}
|
|
|
|
| 100 |
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 101 |
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 102 |
|
| 103 |
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 104 |
-
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if (getRes.status !== 200) {
|
| 106 |
const txt = await getRes.text();
|
| 107 |
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 108 |
}
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
return json;
|
| 112 |
}
|
| 113 |
|
|
|
|
| 114 |
async function gradioPredictLegacy(base: string, body: any) {
|
| 115 |
const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
|
| 116 |
for (const url of endpoints) {
|
| 117 |
try {
|
| 118 |
-
const { res } = await timedFetch(url, {
|
| 119 |
method: "POST",
|
| 120 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
body: JSON.stringify(body),
|
| 122 |
cache: "no-store",
|
| 123 |
});
|
| 124 |
const txt = await res.text();
|
| 125 |
-
const json = (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if (res.ok) return json;
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
throw new Error("Legacy /predict endpoints unavailable.");
|
| 130 |
}
|
| 131 |
|
| 132 |
-
/** Normalize image payload to a single URL string (or data URI) */
|
| 133 |
function extractImageUrl(payload: any): string {
|
| 134 |
-
// v5 often returns
|
| 135 |
-
let d = payload?.data ?? payload;
|
| 136 |
-
|
|
|
|
| 137 |
const first = d[0];
|
| 138 |
-
if (
|
| 139 |
-
if (
|
| 140 |
-
if (first?.
|
| 141 |
}
|
| 142 |
if (d?.url) return String(d.url);
|
|
|
|
|
|
|
| 143 |
throw new Error(`Unexpected image response: ${abbrev(payload)}`);
|
| 144 |
}
|
| 145 |
|
|
@@ -160,36 +281,29 @@ export async function generateImage(options: {
|
|
| 160 |
const seed = (options?.seed ? options.seed : 0) || generateSeed();
|
| 161 |
const width = getValidNumber(options?.width, 256, 1024, 512);
|
| 162 |
const height = getValidNumber(options?.height, 256, 1024, 512);
|
| 163 |
-
const nbSteps = getValidNumber(options?.nbSteps, 1, 12, 4); // server clamps to <=12
|
| 164 |
-
|
| 165 |
-
const positive = [
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
const negative = [
|
| 173 |
-
negativePrompt,
|
| 174 |
-
"watermark",
|
| 175 |
-
"copyright",
|
| 176 |
-
"blurry",
|
| 177 |
-
"low quality",
|
| 178 |
-
"ugly",
|
| 179 |
-
].filter(Boolean).join(", ");
|
| 180 |
|
| 181 |
// Order must match the Space: [prompt, negative, seed, width, height, guidance, steps, secret_token]
|
| 182 |
const dataArray = [positive, negative, seed, width, height, 0.0, nbSteps, SECRET];
|
| 183 |
|
| 184 |
-
// 1) Gradio v5
|
| 185 |
try {
|
| 186 |
const json = await gradioCallV5(BASE, "generate", dataArray);
|
| 187 |
return extractImageUrl(json);
|
| 188 |
} catch (e) {
|
|
|
|
| 189 |
console.warn("Gradio v5 image call failed:", (e as any)?.message || e);
|
| 190 |
}
|
| 191 |
|
| 192 |
-
// 2) Legacy fallback (
|
| 193 |
const legacy = await gradioPredictLegacy(BASE, { fn_index: 0, data: dataArray });
|
| 194 |
return extractImageUrl(legacy);
|
| 195 |
}
|
|
|
|
| 6 |
|
| 7 |
const BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 8 |
const SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || "";
|
| 9 |
+
const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || "";
|
| 10 |
const DEBUG = (process.env.DEBUG_IMAGE_API || "").toLowerCase() === "true";
|
| 11 |
|
| 12 |
function assertEnv() {
|
|
|
|
| 14 |
if (!SECRET) throw new Error("Missing AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN");
|
| 15 |
}
|
| 16 |
|
| 17 |
+
function authHeaders(): Record<string, string> {
|
| 18 |
+
const h: Record<string, string> = {};
|
| 19 |
+
if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
|
| 20 |
+
return h;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
function abbrev(s: unknown, n = 1200) {
|
| 24 |
+
if (s == null) return String(s);
|
| 25 |
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 26 |
+
return t.length > n ? t.slice(0, n) + "…" : t;
|
| 27 |
}
|
| 28 |
|
| 29 |
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
|
|
| 32 |
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)) as Promise<T>,
|
| 33 |
]);
|
| 34 |
}
|
| 35 |
+
|
| 36 |
async function timedFetch(url: string, init?: RequestInit) {
|
| 37 |
const t0 = Date.now();
|
| 38 |
+
const res = await withTimeout(fetch(url, init), 120_000);
|
| 39 |
return { res, ms: Date.now() - t0 };
|
| 40 |
}
|
| 41 |
+
|
| 42 |
function stripToJson(text: string) {
|
| 43 |
if (!text) return "";
|
| 44 |
const iBrace = text.indexOf("{");
|
| 45 |
const iBrack = text.indexOf("[");
|
| 46 |
+
const picks = [iBrace, iBrack].filter((i) => i >= 0);
|
| 47 |
+
if (!picks.length) return text;
|
| 48 |
+
const i = Math.min(...picks);
|
| 49 |
+
return text.slice(i);
|
| 50 |
}
|
| 51 |
+
|
| 52 |
+
/** Parse Gradio SSE and return the JSON payload of the **complete** event (throw on error). */
|
| 53 |
+
async function readSSEComplete(res: Response, { maxMs = 120_000 } = {}): Promise<any | null> {
|
| 54 |
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
| 55 |
+
|
| 56 |
+
// Some deployments return JSON directly (no SSE) — accept that.
|
| 57 |
if (ct.includes("application/json")) {
|
| 58 |
const txt = await res.text();
|
| 59 |
+
try {
|
| 60 |
+
return JSON.parse(stripToJson(txt));
|
| 61 |
+
} catch {
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
}
|
| 65 |
+
|
| 66 |
const reader = res.body?.getReader();
|
| 67 |
if (!reader) return null;
|
| 68 |
+
|
| 69 |
const decoder = new TextDecoder();
|
| 70 |
let buf = "";
|
| 71 |
+
let currEvent: string | null = null;
|
| 72 |
+
let dataLines: string[] = [];
|
| 73 |
+
const started = Date.now();
|
| 74 |
+
|
| 75 |
+
const handleBlock = () => {
|
| 76 |
+
if (!currEvent) return undefined;
|
| 77 |
+
const payloadText = dataLines.join("\n");
|
| 78 |
+
if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
|
| 79 |
+
|
| 80 |
+
if (currEvent === "complete") {
|
| 81 |
+
if (!payloadText) return { type: "complete", json: null as any };
|
| 82 |
+
try {
|
| 83 |
+
return { type: "complete", json: JSON.parse(stripToJson(payloadText)) };
|
| 84 |
+
} catch {
|
| 85 |
+
return { type: "complete", json: null as any };
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (currEvent === "error") {
|
| 90 |
+
let detail: unknown = null;
|
| 91 |
+
try {
|
| 92 |
+
detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
|
| 93 |
+
} catch {
|
| 94 |
+
detail = payloadText;
|
| 95 |
}
|
| 96 |
+
return { type: "error", detail };
|
| 97 |
}
|
| 98 |
+
|
| 99 |
+
return undefined;
|
| 100 |
};
|
| 101 |
|
| 102 |
while (true) {
|
| 103 |
+
if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 104 |
const { value, done } = await reader.read();
|
| 105 |
if (done) break;
|
| 106 |
+
|
| 107 |
+
buf += decoder.decode(value, { stream: true });
|
| 108 |
+
|
| 109 |
+
// split stream into event blocks on blank line
|
| 110 |
+
let idx: number;
|
| 111 |
+
while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
|
| 112 |
+
const raw = buf.slice(0, idx);
|
| 113 |
+
buf = buf.slice(idx).replace(/^\r?\n\r?\n/, ""); // drop exactly one blank separator
|
| 114 |
+
const lines = raw.split(/\r?\n/);
|
| 115 |
+
|
| 116 |
+
// parse fields in this block
|
| 117 |
+
currEvent = null;
|
| 118 |
+
dataLines = [];
|
| 119 |
+
for (const line of lines) {
|
| 120 |
+
if (!line) continue;
|
| 121 |
+
if (line[0] === ":") continue; // comment/keepalive
|
| 122 |
+
const c = line.indexOf(":");
|
| 123 |
+
const field = (c === -1 ? line : line.slice(0, c)).trim();
|
| 124 |
+
const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
|
| 125 |
+
if (field === "event") currEvent = val;
|
| 126 |
+
else if (field === "data") dataLines.push(val);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const outcome = handleBlock();
|
| 130 |
+
if (outcome?.type === "complete") return outcome.json;
|
| 131 |
+
if (outcome?.type === "error") {
|
| 132 |
+
const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
|
| 133 |
+
throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// process any trailing block (stream ended without final blank line)
|
| 139 |
+
if (buf.trim().length) {
|
| 140 |
+
const lines = buf.split(/\r?\n/);
|
| 141 |
+
currEvent = null;
|
| 142 |
+
dataLines = [];
|
| 143 |
+
for (const line of lines) {
|
| 144 |
+
if (!line || line[0] === ":") continue;
|
| 145 |
+
const c = line.indexOf(":");
|
| 146 |
+
const field = (c === -1 ? line : line.slice(0, c)).trim();
|
| 147 |
+
const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
|
| 148 |
+
if (field === "event") currEvent = val;
|
| 149 |
+
else if (field === "data") dataLines.push(val);
|
| 150 |
+
}
|
| 151 |
+
const outcome = handleBlock();
|
| 152 |
+
if (outcome?.type === "complete") return outcome.json;
|
| 153 |
+
if (outcome?.type === "error") {
|
| 154 |
+
const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
|
| 155 |
+
throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
|
| 156 |
+
}
|
| 157 |
}
|
| 158 |
+
|
| 159 |
+
return null;
|
| 160 |
}
|
| 161 |
|
| 162 |
+
/** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE complete). */
|
| 163 |
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 164 |
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 165 |
+
|
| 166 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 167 |
method: "POST",
|
| 168 |
+
headers: {
|
| 169 |
+
"Content-Type": "application/json",
|
| 170 |
+
Accept: "application/json",
|
| 171 |
+
...authHeaders(),
|
| 172 |
+
},
|
| 173 |
body: JSON.stringify({ data: dataArray }),
|
| 174 |
cache: "no-store",
|
| 175 |
});
|
| 176 |
+
|
| 177 |
const postTxt = await postRes.text();
|
| 178 |
+
const postJson = (() => {
|
| 179 |
+
try {
|
| 180 |
+
return JSON.parse(stripToJson(postTxt));
|
| 181 |
+
} catch {
|
| 182 |
+
return null;
|
| 183 |
+
}
|
| 184 |
+
})();
|
| 185 |
+
|
| 186 |
if (!postRes.ok) {
|
| 187 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postTxt ?? "(empty body)";
|
| 188 |
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 189 |
}
|
| 190 |
+
|
| 191 |
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 192 |
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 193 |
|
| 194 |
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 195 |
+
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 196 |
+
method: "GET",
|
| 197 |
+
headers: {
|
| 198 |
+
Accept: "text/event-stream",
|
| 199 |
+
Connection: "keep-alive",
|
| 200 |
+
...authHeaders(),
|
| 201 |
+
},
|
| 202 |
+
cache: "no-store",
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
if (getRes.status !== 200) {
|
| 206 |
const txt = await getRes.text();
|
| 207 |
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 208 |
}
|
| 209 |
+
|
| 210 |
+
const json = await readSSEComplete(getRes);
|
| 211 |
+
if (json == null) {
|
| 212 |
+
// Common causes: invalid secret (server raises gr.Error), or ZeroGPU quota/denied.
|
| 213 |
+
throw new Error(`GET ${getUrl} returned no payload (SSE error/null — check secret/quota).`);
|
| 214 |
+
}
|
| 215 |
return json;
|
| 216 |
}
|
| 217 |
|
| 218 |
+
/** Legacy /api|/run/predict fallback (rarely present on v5 Spaces). */
|
| 219 |
async function gradioPredictLegacy(base: string, body: any) {
|
| 220 |
const endpoints = [`${base}/api/predict`, `${base}/run/predict`];
|
| 221 |
for (const url of endpoints) {
|
| 222 |
try {
|
| 223 |
+
const { res, ms } = await timedFetch(url, {
|
| 224 |
method: "POST",
|
| 225 |
+
headers: {
|
| 226 |
+
"Content-Type": "application/json",
|
| 227 |
+
Accept: "application/json",
|
| 228 |
+
...authHeaders(),
|
| 229 |
+
},
|
| 230 |
body: JSON.stringify(body),
|
| 231 |
cache: "no-store",
|
| 232 |
});
|
| 233 |
const txt = await res.text();
|
| 234 |
+
const json = (() => {
|
| 235 |
+
try {
|
| 236 |
+
return JSON.parse(stripToJson(txt));
|
| 237 |
+
} catch {
|
| 238 |
+
return null;
|
| 239 |
+
}
|
| 240 |
+
})();
|
| 241 |
if (res.ok) return json;
|
| 242 |
+
if (DEBUG) console.warn(`Legacy ${url} → ${res.status} in ${ms}ms — ${abbrev(txt)}`);
|
| 243 |
+
} catch (e) {
|
| 244 |
+
if (DEBUG) console.warn(`Legacy ${url} failed:`, (e as any)?.message || e);
|
| 245 |
+
}
|
| 246 |
}
|
| 247 |
throw new Error("Legacy /predict endpoints unavailable.");
|
| 248 |
}
|
| 249 |
|
| 250 |
+
/** Normalize image payload to a single URL string (or data URI). */
|
| 251 |
function extractImageUrl(payload: any): string {
|
| 252 |
+
// v5 often returns: { data: [ { url, name, size, … } ] }
|
| 253 |
+
let d: any = payload?.data ?? payload;
|
| 254 |
+
|
| 255 |
+
if (Array.isArray(d) && d.length > 0) {
|
| 256 |
const first = d[0];
|
| 257 |
+
if (typeof first === "string") return first; // direct url/data URI
|
| 258 |
+
if (first?.url) return String(first.url); // FileData.url
|
| 259 |
+
if (first?.path) return String(first.path); // legacy file path
|
| 260 |
}
|
| 261 |
if (d?.url) return String(d.url);
|
| 262 |
+
if (d?.path) return String(d.path);
|
| 263 |
+
|
| 264 |
throw new Error(`Unexpected image response: ${abbrev(payload)}`);
|
| 265 |
}
|
| 266 |
|
|
|
|
| 281 |
const seed = (options?.seed ? options.seed : 0) || generateSeed();
|
| 282 |
const width = getValidNumber(options?.width, 256, 1024, 512);
|
| 283 |
const height = getValidNumber(options?.height, 256, 1024, 512);
|
| 284 |
+
const nbSteps = getValidNumber(options?.nbSteps, 1, 12, 4); // server clamps to <=12
|
| 285 |
+
|
| 286 |
+
const positive = ["beautiful", positivePrompt, "award winning", "high resolution"]
|
| 287 |
+
.filter(Boolean)
|
| 288 |
+
.join(", ");
|
| 289 |
+
|
| 290 |
+
const negative = [negativePrompt, "watermark", "copyright", "blurry", "low quality", "ugly"]
|
| 291 |
+
.filter(Boolean)
|
| 292 |
+
.join(", ");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
// Order must match the Space: [prompt, negative, seed, width, height, guidance, steps, secret_token]
|
| 295 |
const dataArray = [positive, negative, seed, width, height, 0.0, nbSteps, SECRET];
|
| 296 |
|
| 297 |
+
// 1) Gradio v5 call API (preferred)
|
| 298 |
try {
|
| 299 |
const json = await gradioCallV5(BASE, "generate", dataArray);
|
| 300 |
return extractImageUrl(json);
|
| 301 |
} catch (e) {
|
| 302 |
+
// If you see "SSE error/null", it’s almost always invalid secret or quota.
|
| 303 |
console.warn("Gradio v5 image call failed:", (e as any)?.message || e);
|
| 304 |
}
|
| 305 |
|
| 306 |
+
// 2) Legacy fallback (very likely 404 on v5 Spaces)
|
| 307 |
const legacy = await gradioPredictLegacy(BASE, { fn_index: 0, data: dataArray });
|
| 308 |
return extractImageUrl(legacy);
|
| 309 |
}
|
src/app/server/actions/generateStoryLines.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
| 3 |
import "server-only";
|
| 4 |
import type { StoryLine, TTSVoice } from "@/types";
|
| 5 |
|
|
|
|
| 6 |
const BASE = (process.env.AI_STORY_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 7 |
const SECRET = process.env.AI_STORY_API_SECRET_TOKEN || "";
|
| 8 |
-
const LEGACY_FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); //
|
|
|
|
| 9 |
const DEBUG = (process.env.DEBUG_STORY_API || "").toLowerCase() === "true";
|
| 10 |
|
| 11 |
function assertEnv() {
|
|
@@ -13,9 +15,16 @@ function assertEnv() {
|
|
| 13 |
if (!SECRET) throw new Error("Missing AI_STORY_API_SECRET_TOKEN");
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
function abbrev(s: unknown, n = 1200) {
|
|
|
|
| 17 |
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 18 |
-
return t
|
| 19 |
}
|
| 20 |
|
| 21 |
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
@@ -27,7 +36,7 @@ async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
| 27 |
|
| 28 |
async function timedFetch(url: string, init?: RequestInit) {
|
| 29 |
const t0 = Date.now();
|
| 30 |
-
const res = await withTimeout(fetch(url, init));
|
| 31 |
return { res, ms: Date.now() - t0 };
|
| 32 |
}
|
| 33 |
|
|
@@ -35,99 +44,174 @@ function stripToJson(text: string) {
|
|
| 35 |
if (!text) return "";
|
| 36 |
const iBrace = text.indexOf("{");
|
| 37 |
const iBrack = text.indexOf("[");
|
| 38 |
-
const
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
/**
|
| 43 |
-
async function
|
| 44 |
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
|
|
|
|
|
| 45 |
if (ct.includes("application/json")) {
|
| 46 |
const txt = await res.text();
|
| 47 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
-
// Parse text/event-stream
|
| 51 |
const reader = res.body?.getReader();
|
| 52 |
if (!reader) return null;
|
| 53 |
|
| 54 |
const decoder = new TextDecoder();
|
| 55 |
let buf = "";
|
| 56 |
-
let
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
const field = idx === -1 ? line.trim() : line.slice(0, idx).trim();
|
| 72 |
-
const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^\s/, "");
|
| 73 |
-
if (DEBUG && line.trim()) console.log("[SSE]", line);
|
| 74 |
-
if (field === "data") dataLines.push(value);
|
| 75 |
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
}
|
|
|
|
| 85 |
}
|
|
|
|
|
|
|
| 86 |
};
|
| 87 |
|
| 88 |
while (true) {
|
| 89 |
-
if (Date.now() -
|
| 90 |
const { value, done } = await reader.read();
|
| 91 |
if (done) break;
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
-
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
-
/** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE). */
|
| 98 |
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 99 |
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 100 |
|
| 101 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 102 |
method: "POST",
|
| 103 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
body: JSON.stringify({ data: dataArray }),
|
| 105 |
cache: "no-store",
|
| 106 |
});
|
| 107 |
|
| 108 |
const postText = await postRes.text();
|
| 109 |
-
const postJson = (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
if (!postRes.ok) {
|
| 112 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? "(empty body)";
|
| 113 |
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 114 |
}
|
|
|
|
| 115 |
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 116 |
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 117 |
|
| 118 |
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 119 |
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 120 |
method: "GET",
|
| 121 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
});
|
|
|
|
| 123 |
if (getRes.status !== 200) {
|
| 124 |
const txt = await getRes.text();
|
| 125 |
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 126 |
}
|
| 127 |
|
| 128 |
-
const json = await
|
| 129 |
if (json == null) {
|
| 130 |
-
// Gradio sometimes sends
|
| 131 |
throw new Error(`GET ${getUrl} returned no payload (SSE error/null).`);
|
| 132 |
}
|
| 133 |
return json;
|
|
@@ -140,12 +224,24 @@ async function gradioPredictLegacy(base: string, body: any) {
|
|
| 140 |
try {
|
| 141 |
const { res, ms } = await timedFetch(url, {
|
| 142 |
method: "POST",
|
| 143 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
body: JSON.stringify(body),
|
| 145 |
cache: "no-store",
|
| 146 |
});
|
|
|
|
| 147 |
const txt = await res.text();
|
| 148 |
-
const json = (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
if (res.ok) return json;
|
| 150 |
if (DEBUG) console.warn(`Legacy ${url} → ${res.status} in ${ms}ms — ${abbrev(txt)}`);
|
| 151 |
} catch (e) {
|
|
@@ -158,11 +254,11 @@ async function gradioPredictLegacy(base: string, body: any) {
|
|
| 158 |
/** Normalize various Gradio payload shapes into StoryLine[] */
|
| 159 |
function extractStoryLines(payload: any): StoryLine[] {
|
| 160 |
let d = payload?.data ?? payload;
|
|
|
|
| 161 |
if (!Array.isArray(d)) throw new Error(`Unexpected response: ${abbrev(payload)}`);
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
text: String(l?.text || "")
|
| 166 |
.replaceAll(" .", ".")
|
| 167 |
.replaceAll(" ,", ",")
|
| 168 |
.replaceAll(" !", "!")
|
|
@@ -179,19 +275,17 @@ export async function generateStoryLines(prompt: string, voice: TTSVoice): Promi
|
|
| 179 |
throw new Error("prompt is too short!");
|
| 180 |
}
|
| 181 |
const cropped = prompt.slice(0, 30);
|
| 182 |
-
console.log(`user requested "${cropped}${cropped !== prompt ? "..." : ""}"`);
|
| 183 |
|
| 184 |
-
// 1) Preferred: Gradio v5 call API
|
| 185 |
try {
|
| 186 |
const json = await gradioCallV5(BASE, "predict", [SECRET, prompt, voice]);
|
| 187 |
return extractStoryLines(json);
|
| 188 |
} catch (e) {
|
| 189 |
-
// If the backend returned SSE error/null, surface a clean message,
|
| 190 |
-
// but still try legacy endpoints once for maximum compatibility.
|
| 191 |
console.warn("Gradio v5 call failed:", (e as any)?.message || e);
|
| 192 |
}
|
| 193 |
|
| 194 |
-
// 2) Fallback: legacy JSON endpoints
|
| 195 |
const legacy = await gradioPredictLegacy(BASE, { fn_index: LEGACY_FN_INDEX, data: [SECRET, prompt, voice] });
|
| 196 |
return extractStoryLines(legacy);
|
| 197 |
}
|
|
|
|
| 3 |
import "server-only";
|
| 4 |
import type { StoryLine, TTSVoice } from "@/types";
|
| 5 |
|
| 6 |
+
// ---- env / config ----
|
| 7 |
const BASE = (process.env.AI_STORY_API_GRADIO_URL || "").replace(/\/+$/, "");
|
| 8 |
const SECRET = process.env.AI_STORY_API_SECRET_TOKEN || "";
|
| 9 |
+
const LEGACY_FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // fallback only
|
| 10 |
+
const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || "";
|
| 11 |
const DEBUG = (process.env.DEBUG_STORY_API || "").toLowerCase() === "true";
|
| 12 |
|
| 13 |
function assertEnv() {
|
|
|
|
| 15 |
if (!SECRET) throw new Error("Missing AI_STORY_API_SECRET_TOKEN");
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function authHeaders(): Record<string, string> {
|
| 19 |
+
const h: Record<string, string> = {};
|
| 20 |
+
if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
|
| 21 |
+
return h;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
function abbrev(s: unknown, n = 1200) {
|
| 25 |
+
if (s == null) return String(s);
|
| 26 |
const t = typeof s === "string" ? s : JSON.stringify(s);
|
| 27 |
+
return t.length > n ? t.slice(0, n) + "…" : t;
|
| 28 |
}
|
| 29 |
|
| 30 |
async function withTimeout<T>(p: Promise<T>, ms = 120_000) {
|
|
|
|
| 36 |
|
| 37 |
async function timedFetch(url: string, init?: RequestInit) {
|
| 38 |
const t0 = Date.now();
|
| 39 |
+
const res = await withTimeout(fetch(url, init), 120_000);
|
| 40 |
return { res, ms: Date.now() - t0 };
|
| 41 |
}
|
| 42 |
|
|
|
|
| 44 |
if (!text) return "";
|
| 45 |
const iBrace = text.indexOf("{");
|
| 46 |
const iBrack = text.indexOf("[");
|
| 47 |
+
const picks = [iBrace, iBrack].filter((i) => i >= 0);
|
| 48 |
+
if (!picks.length) return text;
|
| 49 |
+
const i = Math.min(...picks);
|
| 50 |
+
return text.slice(i);
|
| 51 |
}
|
| 52 |
|
| 53 |
+
/** Parse SSE and return the JSON payload of the **complete** event (throws on error). */
|
| 54 |
+
async function readSSEComplete(res: Response, { maxMs = 120_000 } = {}): Promise<any | null> {
|
| 55 |
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
| 56 |
+
|
| 57 |
+
// Some deployments return plain JSON (no SSE) — accept that.
|
| 58 |
if (ct.includes("application/json")) {
|
| 59 |
const txt = await res.text();
|
| 60 |
+
try {
|
| 61 |
+
return JSON.parse(stripToJson(txt));
|
| 62 |
+
} catch {
|
| 63 |
+
return null;
|
| 64 |
+
}
|
| 65 |
}
|
| 66 |
|
|
|
|
| 67 |
const reader = res.body?.getReader();
|
| 68 |
if (!reader) return null;
|
| 69 |
|
| 70 |
const decoder = new TextDecoder();
|
| 71 |
let buf = "";
|
| 72 |
+
let currEvent: string | null = null;
|
| 73 |
+
let dataLines: string[] = [];
|
| 74 |
+
const started = Date.now();
|
| 75 |
+
|
| 76 |
+
const handleBlock = () => {
|
| 77 |
+
if (!currEvent) return undefined;
|
| 78 |
+
const payloadText = dataLines.join("\n");
|
| 79 |
+
if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
|
| 80 |
+
|
| 81 |
+
if (currEvent === "complete") {
|
| 82 |
+
if (!payloadText) return { type: "complete", json: null as any };
|
| 83 |
+
try {
|
| 84 |
+
return { type: "complete", json: JSON.parse(stripToJson(payloadText)) };
|
| 85 |
+
} catch {
|
| 86 |
+
return { type: "complete", json: null as any };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (currEvent === "error") {
|
| 91 |
+
let detail: unknown = null;
|
| 92 |
+
try {
|
| 93 |
+
detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
|
| 94 |
+
} catch {
|
| 95 |
+
detail = payloadText;
|
| 96 |
}
|
| 97 |
+
return { type: "error", detail };
|
| 98 |
}
|
| 99 |
+
|
| 100 |
+
return undefined;
|
| 101 |
};
|
| 102 |
|
| 103 |
while (true) {
|
| 104 |
+
if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 105 |
const { value, done } = await reader.read();
|
| 106 |
if (done) break;
|
| 107 |
+
|
| 108 |
+
buf += decoder.decode(value, { stream: true });
|
| 109 |
+
|
| 110 |
+
// split stream into event blocks on blank line
|
| 111 |
+
let idx: number;
|
| 112 |
+
// handle consecutive CRLF CRLF properly
|
| 113 |
+
while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
|
| 114 |
+
const raw = buf.slice(0, idx);
|
| 115 |
+
buf = buf.slice(idx).replace(/^\r?\n\r?\n/, ""); // remove exactly one blank-separator
|
| 116 |
+
const lines = raw.split(/\r?\n/);
|
| 117 |
+
|
| 118 |
+
// parse fields in this block
|
| 119 |
+
currEvent = null;
|
| 120 |
+
dataLines = [];
|
| 121 |
+
for (const line of lines) {
|
| 122 |
+
if (!line) continue;
|
| 123 |
+
if (line[0] === ":") continue; // comment/keepalive
|
| 124 |
+
const c = line.indexOf(":");
|
| 125 |
+
const field = (c === -1 ? line : line.slice(0, c)).trim();
|
| 126 |
+
const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
|
| 127 |
+
if (field === "event") currEvent = val;
|
| 128 |
+
else if (field === "data") dataLines.push(val);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const outcome = handleBlock();
|
| 132 |
+
if (outcome?.type === "complete") return outcome.json;
|
| 133 |
+
if (outcome?.type === "error") {
|
| 134 |
+
const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
|
| 135 |
+
throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Process any trailing block (if stream ended without final blank line)
|
| 141 |
+
if (buf.trim().length) {
|
| 142 |
+
const lines = buf.split(/\r?\n/);
|
| 143 |
+
currEvent = null;
|
| 144 |
+
dataLines = [];
|
| 145 |
+
for (const line of lines) {
|
| 146 |
+
if (!line || line[0] === ":") continue;
|
| 147 |
+
const c = line.indexOf(":");
|
| 148 |
+
const field = (c === -1 ? line : line.slice(0, c)).trim();
|
| 149 |
+
const val = (c === -1 ? "" : line.slice(c + 1)).replace(/^\s/, "");
|
| 150 |
+
if (field === "event") currEvent = val;
|
| 151 |
+
else if (field === "data") dataLines.push(val);
|
| 152 |
+
}
|
| 153 |
+
const outcome = handleBlock();
|
| 154 |
+
if (outcome?.type === "complete") return outcome.json;
|
| 155 |
+
if (outcome?.type === "error") {
|
| 156 |
+
const msg = typeof outcome.detail === "string" ? outcome.detail : JSON.stringify(outcome.detail);
|
| 157 |
+
throw new Error(`SSE error event: ${abbrev(msg ?? "null", 1200)}`);
|
| 158 |
+
}
|
| 159 |
}
|
| 160 |
+
|
| 161 |
+
return null;
|
| 162 |
}
|
| 163 |
|
| 164 |
+
/** v5 call API: POST /gradio_api/call/<fn> → {event_id} → GET /gradio_api/call/<fn>/<event_id> (SSE complete). */
|
| 165 |
async function gradioCallV5(base: string, fnName: string, dataArray: any[]) {
|
| 166 |
const callUrl = `${base}/gradio_api/call/${fnName}`;
|
| 167 |
|
| 168 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 169 |
method: "POST",
|
| 170 |
+
headers: {
|
| 171 |
+
"Content-Type": "application/json",
|
| 172 |
+
Accept: "application/json",
|
| 173 |
+
...authHeaders(),
|
| 174 |
+
},
|
| 175 |
body: JSON.stringify({ data: dataArray }),
|
| 176 |
cache: "no-store",
|
| 177 |
});
|
| 178 |
|
| 179 |
const postText = await postRes.text();
|
| 180 |
+
const postJson = (() => {
|
| 181 |
+
try {
|
| 182 |
+
return JSON.parse(stripToJson(postText));
|
| 183 |
+
} catch {
|
| 184 |
+
return null;
|
| 185 |
+
}
|
| 186 |
+
})();
|
| 187 |
|
| 188 |
if (!postRes.ok) {
|
| 189 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? "(empty body)";
|
| 190 |
throw new Error(`POST ${callUrl} → ${postRes.status} in ${postMs}ms — ${abbrev(detail)}`);
|
| 191 |
}
|
| 192 |
+
|
| 193 |
const eventId = postJson?.event_id || postJson?.eventId || postJson?.eventID;
|
| 194 |
if (!eventId) throw new Error(`POST ${callUrl} returned no event_id`);
|
| 195 |
|
| 196 |
const getUrl = `${base}/gradio_api/call/${fnName}/${eventId}`;
|
| 197 |
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 198 |
method: "GET",
|
| 199 |
+
headers: {
|
| 200 |
+
Accept: "text/event-stream",
|
| 201 |
+
Connection: "keep-alive",
|
| 202 |
+
...authHeaders(),
|
| 203 |
+
},
|
| 204 |
+
cache: "no-store",
|
| 205 |
});
|
| 206 |
+
|
| 207 |
if (getRes.status !== 200) {
|
| 208 |
const txt = await getRes.text();
|
| 209 |
throw new Error(`GET ${getUrl} → ${getRes.status} in ${getMs}ms — ${abbrev(txt)}`);
|
| 210 |
}
|
| 211 |
|
| 212 |
+
const json = await readSSEComplete(getRes);
|
| 213 |
if (json == null) {
|
| 214 |
+
// Gradio sometimes sends event:error with data:null
|
| 215 |
throw new Error(`GET ${getUrl} returned no payload (SSE error/null).`);
|
| 216 |
}
|
| 217 |
return json;
|
|
|
|
| 224 |
try {
|
| 225 |
const { res, ms } = await timedFetch(url, {
|
| 226 |
method: "POST",
|
| 227 |
+
headers: {
|
| 228 |
+
"Content-Type": "application/json",
|
| 229 |
+
Accept: "application/json",
|
| 230 |
+
...authHeaders(),
|
| 231 |
+
},
|
| 232 |
body: JSON.stringify(body),
|
| 233 |
cache: "no-store",
|
| 234 |
});
|
| 235 |
+
|
| 236 |
const txt = await res.text();
|
| 237 |
+
const json = (() => {
|
| 238 |
+
try {
|
| 239 |
+
return JSON.parse(stripToJson(txt));
|
| 240 |
+
} catch {
|
| 241 |
+
return null;
|
| 242 |
+
}
|
| 243 |
+
})();
|
| 244 |
+
|
| 245 |
if (res.ok) return json;
|
| 246 |
if (DEBUG) console.warn(`Legacy ${url} → ${res.status} in ${ms}ms — ${abbrev(txt)}`);
|
| 247 |
} catch (e) {
|
|
|
|
| 254 |
/** Normalize various Gradio payload shapes into StoryLine[] */
|
| 255 |
function extractStoryLines(payload: any): StoryLine[] {
|
| 256 |
let d = payload?.data ?? payload;
|
| 257 |
+
if (Array.isArray(d) && Array.isArray(d[0])) d = d[0];
|
| 258 |
if (!Array.isArray(d)) throw new Error(`Unexpected response: ${abbrev(payload)}`);
|
| 259 |
+
|
| 260 |
+
return d.map((l: any): StoryLine => ({
|
| 261 |
+
text: String(l?.text ?? "")
|
|
|
|
| 262 |
.replaceAll(" .", ".")
|
| 263 |
.replaceAll(" ,", ",")
|
| 264 |
.replaceAll(" !", "!")
|
|
|
|
| 275 |
throw new Error("prompt is too short!");
|
| 276 |
}
|
| 277 |
const cropped = prompt.slice(0, 30);
|
| 278 |
+
if (DEBUG) console.log(`user requested "${cropped}${cropped !== prompt ? "..." : ""}"`);
|
| 279 |
|
| 280 |
+
// 1) Preferred: Gradio v5 call API (SSE complete)
|
| 281 |
try {
|
| 282 |
const json = await gradioCallV5(BASE, "predict", [SECRET, prompt, voice]);
|
| 283 |
return extractStoryLines(json);
|
| 284 |
} catch (e) {
|
|
|
|
|
|
|
| 285 |
console.warn("Gradio v5 call failed:", (e as any)?.message || e);
|
| 286 |
}
|
| 287 |
|
| 288 |
+
// 2) Fallback: legacy JSON endpoints
|
| 289 |
const legacy = await gradioPredictLegacy(BASE, { fn_index: LEGACY_FN_INDEX, data: [SECRET, prompt, voice] });
|
| 290 |
return extractStoryLines(legacy);
|
| 291 |
}
|
tests/test_connections/test_endpoints.mjs
CHANGED
|
@@ -7,86 +7,135 @@ const STORY_SECRET = process.env.AI_STORY_API_SECRET_TOKEN || '';
|
|
| 7 |
const IMG_BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || '').replace(/\/+$/, '');
|
| 8 |
const IMG_SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || '';
|
| 9 |
|
|
|
|
|
|
|
| 10 |
const FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // legacy only
|
| 11 |
const DEBUG = (process.env.DEBUG_STORY_API || '').toLowerCase() === 'true';
|
| 12 |
|
| 13 |
function abbrev(s, n = 1800) {
|
| 14 |
if (s == null) return String(s);
|
| 15 |
-
|
| 16 |
-
return
|
| 17 |
}
|
|
|
|
| 18 |
function assertEnv() {
|
| 19 |
if (!STORY_BASE) throw new Error('Missing AI_STORY_API_GRADIO_URL in .env');
|
| 20 |
if (!STORY_SECRET) throw new Error('Missing AI_STORY_API_SECRET_TOKEN in .env');
|
| 21 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
async function withTimeout(promise, ms = 120_000) {
|
| 23 |
return Promise.race([
|
| 24 |
promise,
|
| 25 |
new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)),
|
| 26 |
]);
|
| 27 |
}
|
|
|
|
| 28 |
async function timedFetch(url, init) {
|
| 29 |
const t0 = Date.now();
|
| 30 |
const res = await withTimeout(fetch(url, init));
|
| 31 |
const ms = Date.now() - t0;
|
| 32 |
return { res, ms };
|
| 33 |
}
|
|
|
|
| 34 |
function stripToJson(text) {
|
| 35 |
if (!text) return '';
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
);
|
| 42 |
-
return i === Number.POSITIVE_INFINITY ? text : text.slice(i);
|
| 43 |
}
|
| 44 |
|
| 45 |
-
/** Parse SSE and return the
|
| 46 |
-
async function
|
| 47 |
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
|
|
| 48 |
if (ct.includes('application/json')) {
|
| 49 |
const txt = await res.text();
|
| 50 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
|
|
|
| 53 |
const reader = res.body.getReader();
|
| 54 |
const decoder = new TextDecoder();
|
| 55 |
let buf = '';
|
| 56 |
-
let
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
const idx = line.indexOf(':');
|
| 71 |
-
if (idx === -1) continue;
|
| 72 |
-
const field = line.slice(0, idx).trim();
|
| 73 |
-
const value = line.slice(idx + 1).replace(/^\s/, '');
|
| 74 |
-
if (field === 'data') dataLines.push(value);
|
| 75 |
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
|
|
|
| 80 |
}
|
|
|
|
| 81 |
};
|
| 82 |
|
| 83 |
while (true) {
|
| 84 |
-
if (Date.now() -
|
| 85 |
const { value, done } = await reader.read();
|
| 86 |
if (done) break;
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
-
return
|
| 90 |
}
|
| 91 |
|
| 92 |
/** Gradio v5: POST /gradio_api/call/<fn> -> {event_id}, then GET /gradio_api/call/<fn>/<event_id> (SSE) */
|
|
@@ -99,13 +148,19 @@ async function tryCallApi(base, fnName, dataArray) {
|
|
| 99 |
|
| 100 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 101 |
method: 'POST',
|
| 102 |
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
| 103 |
body: JSON.stringify(postBody),
|
| 104 |
cache: 'no-store',
|
| 105 |
});
|
| 106 |
|
| 107 |
const postText = await postRes.text();
|
| 108 |
-
const postJson = (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
if (!postRes.ok) {
|
| 111 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? '(empty body)';
|
|
@@ -123,7 +178,8 @@ async function tryCallApi(base, fnName, dataArray) {
|
|
| 123 |
|
| 124 |
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 125 |
method: 'GET',
|
| 126 |
-
headers: { Accept: 'text/event-stream' },
|
|
|
|
| 127 |
});
|
| 128 |
|
| 129 |
if (getRes.status !== 200) {
|
|
@@ -131,8 +187,8 @@ async function tryCallApi(base, fnName, dataArray) {
|
|
| 131 |
throw new Error(`HTTP ${getRes.status} ${getRes.statusText} in ${getMs}ms — ${abbrev(txt, 800)}`);
|
| 132 |
}
|
| 133 |
|
| 134 |
-
const json = await
|
| 135 |
-
console.log(`
|
| 136 |
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 137 |
return { ok: true, url: getUrl, json, ms: getMs };
|
| 138 |
}
|
|
@@ -148,24 +204,26 @@ async function tryPredictLegacy(base, body) {
|
|
| 148 |
|
| 149 |
const { res, ms } = await timedFetch(url, {
|
| 150 |
method: 'POST',
|
| 151 |
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
| 152 |
body: JSON.stringify(body),
|
| 153 |
cache: 'no-store',
|
| 154 |
});
|
| 155 |
|
| 156 |
const text = await res.text();
|
| 157 |
let json = null;
|
| 158 |
-
try {
|
|
|
|
|
|
|
| 159 |
if (res.ok) {
|
| 160 |
-
console.log(`
|
| 161 |
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 162 |
return { ok: true, url, json, ms };
|
| 163 |
}
|
| 164 |
|
| 165 |
const detail = json?.detail ?? json?.error ?? json?.message ?? text ?? '(empty body)';
|
| 166 |
-
console.error(`
|
| 167 |
} catch (e) {
|
| 168 |
-
console.error(`
|
| 169 |
}
|
| 170 |
}
|
| 171 |
return { ok: false };
|
|
@@ -186,9 +244,9 @@ async function testStoryEndpoint() {
|
|
| 186 |
console.log('TOKEN:', STORY_SECRET ? '(present)' : '(MISSING)');
|
| 187 |
console.log('FN :', FN_INDEX);
|
| 188 |
|
| 189 |
-
// HEAD liveness
|
| 190 |
try {
|
| 191 |
-
const { res, ms } = await timedFetch(STORY_BASE + '/', { method: 'HEAD' });
|
| 192 |
console.log(`HEAD ${STORY_BASE}/ -> ${res.status} in ${ms}ms`);
|
| 193 |
} catch (e) {
|
| 194 |
console.warn('HEAD failed:', e?.message || e);
|
|
@@ -204,12 +262,11 @@ async function testStoryEndpoint() {
|
|
| 204 |
|
| 205 |
const lines = extractStoryLines(out.json);
|
| 206 |
if (Array.isArray(lines)) {
|
| 207 |
-
console.log(`
|
| 208 |
return;
|
| 209 |
}
|
| 210 |
|
| 211 |
-
|
| 212 |
-
console.warn('⚠️ Story endpoint returned no JSON payload (SSE error/null). Treating as reachable.');
|
| 213 |
return;
|
| 214 |
} catch (e) {
|
| 215 |
console.warn('Gradio v5 call API failed for story:', e?.message || e);
|
|
@@ -222,7 +279,7 @@ async function testStoryEndpoint() {
|
|
| 222 |
if (!Array.isArray(lines)) {
|
| 223 |
console.warn('⚠️ Legacy story payload not recognized, but endpoint responded. Treating as reachable.');
|
| 224 |
} else {
|
| 225 |
-
console.log(`
|
| 226 |
}
|
| 227 |
}
|
| 228 |
|
|
@@ -249,7 +306,7 @@ async function testImageEndpoint() {
|
|
| 249 |
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 250 |
|
| 251 |
if (ok) {
|
| 252 |
-
console.log('
|
| 253 |
return;
|
| 254 |
}
|
| 255 |
console.warn('⚠️ Image payload not recognized, but endpoint responded. Treating as reachable.');
|
|
@@ -266,7 +323,7 @@ async function testImageEndpoint() {
|
|
| 266 |
Array.isArray(payload) ||
|
| 267 |
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 268 |
if (!ok) console.warn('⚠️ Legacy image payload not recognized, but endpoint responded. Treating as reachable.');
|
| 269 |
-
else console.log('
|
| 270 |
}
|
| 271 |
|
| 272 |
(async () => {
|
|
@@ -279,4 +336,4 @@ async function testImageEndpoint() {
|
|
| 279 |
console.error('\nTests failed ❌:', e?.message || e);
|
| 280 |
process.exit(1);
|
| 281 |
}
|
| 282 |
-
})();
|
|
|
|
| 7 |
const IMG_BASE = (process.env.AI_FAST_IMAGE_SERVER_API_GRADIO_URL || '').replace(/\/+$/, '');
|
| 8 |
const IMG_SECRET = process.env.AI_FAST_IMAGE_SERVER_API_SECRET_TOKEN || '';
|
| 9 |
|
| 10 |
+
const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN || '';
|
| 11 |
+
|
| 12 |
const FN_INDEX = Number(process.env.AI_STORY_API_FN_INDEX ?? 0); // legacy only
|
| 13 |
const DEBUG = (process.env.DEBUG_STORY_API || '').toLowerCase() === 'true';
|
| 14 |
|
| 15 |
function abbrev(s, n = 1800) {
|
| 16 |
if (s == null) return String(s);
|
| 17 |
+
const str = typeof s === 'string' ? s : JSON.stringify(s);
|
| 18 |
+
return str.length > n ? str.slice(0, n) + '…' : str;
|
| 19 |
}
|
| 20 |
+
|
| 21 |
function assertEnv() {
|
| 22 |
if (!STORY_BASE) throw new Error('Missing AI_STORY_API_GRADIO_URL in .env');
|
| 23 |
if (!STORY_SECRET) throw new Error('Missing AI_STORY_API_SECRET_TOKEN in .env');
|
| 24 |
}
|
| 25 |
+
|
| 26 |
+
function authHeaders() {
|
| 27 |
+
const h = {};
|
| 28 |
+
if (HF_TOKEN) h.Authorization = `Bearer ${HF_TOKEN}`;
|
| 29 |
+
return h;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
async function withTimeout(promise, ms = 120_000) {
|
| 33 |
return Promise.race([
|
| 34 |
promise,
|
| 35 |
new Promise((_, rej) => setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)),
|
| 36 |
]);
|
| 37 |
}
|
| 38 |
+
|
| 39 |
async function timedFetch(url, init) {
|
| 40 |
const t0 = Date.now();
|
| 41 |
const res = await withTimeout(fetch(url, init));
|
| 42 |
const ms = Date.now() - t0;
|
| 43 |
return { res, ms };
|
| 44 |
}
|
| 45 |
+
|
| 46 |
function stripToJson(text) {
|
| 47 |
if (!text) return '';
|
| 48 |
+
const ixBrace = text.indexOf('{');
|
| 49 |
+
const ixBracket = text.indexOf('[');
|
| 50 |
+
const pick = [ixBrace, ixBracket].filter((i) => i >= 0);
|
| 51 |
+
if (!pick.length) return text;
|
| 52 |
+
const i = Math.min(...pick);
|
| 53 |
+
return text.slice(i);
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
+
/** Parse SSE and return the JSON payload of the **complete** event (throw on error). */
|
| 57 |
+
async function readSSEComplete(res, { maxMs = 120_000 } = {}) {
|
| 58 |
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
| 59 |
+
// Some deployments reply JSON directly (no SSE) — accept that.
|
| 60 |
if (ct.includes('application/json')) {
|
| 61 |
const txt = await res.text();
|
| 62 |
+
try {
|
| 63 |
+
return JSON.parse(stripToJson(txt));
|
| 64 |
+
} catch {
|
| 65 |
+
return null;
|
| 66 |
+
}
|
| 67 |
}
|
| 68 |
|
| 69 |
+
// Proper SSE parse: look for event: complete / event: error
|
| 70 |
const reader = res.body.getReader();
|
| 71 |
const decoder = new TextDecoder();
|
| 72 |
let buf = '';
|
| 73 |
+
let currEvent = null;
|
| 74 |
+
let dataLines = [];
|
| 75 |
+
const started = Date.now();
|
| 76 |
+
|
| 77 |
+
const handleBlock = () => {
|
| 78 |
+
if (!currEvent) return;
|
| 79 |
+
const payloadText = dataLines.join('\n');
|
| 80 |
+
if (DEBUG) console.log(` [SSE event=${currEvent}] ${abbrev(payloadText, 600)}`);
|
| 81 |
+
if (currEvent === 'complete') {
|
| 82 |
+
if (!payloadText) return { type: 'complete', json: null };
|
| 83 |
+
try {
|
| 84 |
+
return { type: 'complete', json: JSON.parse(stripToJson(payloadText)) };
|
| 85 |
+
} catch {
|
| 86 |
+
return { type: 'complete', json: null };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
+
}
|
| 89 |
+
if (currEvent === 'error') {
|
| 90 |
+
let detail = null;
|
| 91 |
+
try {
|
| 92 |
+
detail = payloadText ? JSON.parse(stripToJson(payloadText)) : null;
|
| 93 |
+
} catch {
|
| 94 |
+
detail = payloadText;
|
| 95 |
}
|
| 96 |
+
return { type: 'error', detail };
|
| 97 |
}
|
| 98 |
+
return undefined;
|
| 99 |
};
|
| 100 |
|
| 101 |
while (true) {
|
| 102 |
+
if (Date.now() - started > maxMs) throw new Error(`SSE read timeout after ${maxMs}ms`);
|
| 103 |
const { value, done } = await reader.read();
|
| 104 |
if (done) break;
|
| 105 |
+
buf += decoder.decode(value, { stream: true });
|
| 106 |
+
|
| 107 |
+
// Normalize CRLF and split into blocks
|
| 108 |
+
let idx;
|
| 109 |
+
while ((idx = buf.search(/\r?\n\r?\n/)) !== -1) {
|
| 110 |
+
const raw = buf.slice(0, idx);
|
| 111 |
+
buf = buf.slice(idx).replace(/^\r?\n/, '');
|
| 112 |
+
const lines = raw.split(/\r?\n/);
|
| 113 |
+
|
| 114 |
+
// Parse fields inside this SSE block
|
| 115 |
+
currEvent = null;
|
| 116 |
+
dataLines = [];
|
| 117 |
+
for (const line of lines) {
|
| 118 |
+
if (!line) continue;
|
| 119 |
+
// comment keep-alive lines start with ':'
|
| 120 |
+
if (line[0] === ':') continue;
|
| 121 |
+
const c = line.indexOf(':');
|
| 122 |
+
const field = (c === -1 ? line : line.slice(0, c)).trim();
|
| 123 |
+
const val = (c === -1 ? '' : line.slice(c + 1)).replace(/^\s/, '');
|
| 124 |
+
if (field === 'event') currEvent = val;
|
| 125 |
+
else if (field === 'data') dataLines.push(val);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const outcome = handleBlock();
|
| 129 |
+
if (outcome?.type === 'complete') return outcome.json;
|
| 130 |
+
if (outcome?.type === 'error') {
|
| 131 |
+
const msg = typeof outcome.detail === 'string'
|
| 132 |
+
? outcome.detail
|
| 133 |
+
: JSON.stringify(outcome.detail);
|
| 134 |
+
throw new Error(`SSE error event: ${abbrev(msg ?? 'null', 1200)}`);
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
}
|
| 138 |
+
return null;
|
| 139 |
}
|
| 140 |
|
| 141 |
/** Gradio v5: POST /gradio_api/call/<fn> -> {event_id}, then GET /gradio_api/call/<fn>/<event_id> (SSE) */
|
|
|
|
| 148 |
|
| 149 |
const { res: postRes, ms: postMs } = await timedFetch(callUrl, {
|
| 150 |
method: 'POST',
|
| 151 |
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders() },
|
| 152 |
body: JSON.stringify(postBody),
|
| 153 |
cache: 'no-store',
|
| 154 |
});
|
| 155 |
|
| 156 |
const postText = await postRes.text();
|
| 157 |
+
const postJson = (() => {
|
| 158 |
+
try {
|
| 159 |
+
return JSON.parse(stripToJson(postText));
|
| 160 |
+
} catch {
|
| 161 |
+
return null;
|
| 162 |
+
}
|
| 163 |
+
})();
|
| 164 |
|
| 165 |
if (!postRes.ok) {
|
| 166 |
const detail = postJson?.detail ?? postJson?.error ?? postJson?.message ?? postText ?? '(empty body)';
|
|
|
|
| 178 |
|
| 179 |
const { res: getRes, ms: getMs } = await timedFetch(getUrl, {
|
| 180 |
method: 'GET',
|
| 181 |
+
headers: { Accept: 'text/event-stream', ...authHeaders() },
|
| 182 |
+
cache: 'no-store',
|
| 183 |
});
|
| 184 |
|
| 185 |
if (getRes.status !== 200) {
|
|
|
|
| 187 |
throw new Error(`HTTP ${getRes.status} ${getRes.statusText} in ${getMs}ms — ${abbrev(txt, 800)}`);
|
| 188 |
}
|
| 189 |
|
| 190 |
+
const json = await readSSEComplete(getRes);
|
| 191 |
+
console.log(` ✅ OK 200 in ${getMs}ms`);
|
| 192 |
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 193 |
return { ok: true, url: getUrl, json, ms: getMs };
|
| 194 |
}
|
|
|
|
| 204 |
|
| 205 |
const { res, ms } = await timedFetch(url, {
|
| 206 |
method: 'POST',
|
| 207 |
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders() },
|
| 208 |
body: JSON.stringify(body),
|
| 209 |
cache: 'no-store',
|
| 210 |
});
|
| 211 |
|
| 212 |
const text = await res.text();
|
| 213 |
let json = null;
|
| 214 |
+
try {
|
| 215 |
+
json = JSON.parse(stripToJson(text));
|
| 216 |
+
} catch {}
|
| 217 |
if (res.ok) {
|
| 218 |
+
console.log(` ✅ OK ${res.status} in ${ms}ms`);
|
| 219 |
if (DEBUG) console.log(' json:', abbrev(JSON.stringify(json), 2000));
|
| 220 |
return { ok: true, url, json, ms };
|
| 221 |
}
|
| 222 |
|
| 223 |
const detail = json?.detail ?? json?.error ?? json?.message ?? text ?? '(empty body)';
|
| 224 |
+
console.error(` ❌ HTTP ${res.status} ${res.statusText} in ${ms}ms — ${abbrev(String(detail), 1200)}`);
|
| 225 |
} catch (e) {
|
| 226 |
+
console.error(` ❌ ${url} failed: ${e?.message || e}`);
|
| 227 |
}
|
| 228 |
}
|
| 229 |
return { ok: false };
|
|
|
|
| 244 |
console.log('TOKEN:', STORY_SECRET ? '(present)' : '(MISSING)');
|
| 245 |
console.log('FN :', FN_INDEX);
|
| 246 |
|
| 247 |
+
// HEAD liveness (with auth if provided)
|
| 248 |
try {
|
| 249 |
+
const { res, ms } = await timedFetch(STORY_BASE + '/', { method: 'HEAD', headers: authHeaders() });
|
| 250 |
console.log(`HEAD ${STORY_BASE}/ -> ${res.status} in ${ms}ms`);
|
| 251 |
} catch (e) {
|
| 252 |
console.warn('HEAD failed:', e?.message || e);
|
|
|
|
| 262 |
|
| 263 |
const lines = extractStoryLines(out.json);
|
| 264 |
if (Array.isArray(lines)) {
|
| 265 |
+
console.log(` ✅ Received ${lines.length} story lines`);
|
| 266 |
return;
|
| 267 |
}
|
| 268 |
|
| 269 |
+
console.warn('⚠️ Story endpoint returned no recognized payload (SSE complete missing/empty). Treating as reachable.');
|
|
|
|
| 270 |
return;
|
| 271 |
} catch (e) {
|
| 272 |
console.warn('Gradio v5 call API failed for story:', e?.message || e);
|
|
|
|
| 279 |
if (!Array.isArray(lines)) {
|
| 280 |
console.warn('⚠️ Legacy story payload not recognized, but endpoint responded. Treating as reachable.');
|
| 281 |
} else {
|
| 282 |
+
console.log(` ✅ Legacy story returned ${lines.length} lines`);
|
| 283 |
}
|
| 284 |
}
|
| 285 |
|
|
|
|
| 306 |
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 307 |
|
| 308 |
if (ok) {
|
| 309 |
+
console.log(' ✅ Image endpoint responded.');
|
| 310 |
return;
|
| 311 |
}
|
| 312 |
console.warn('⚠️ Image payload not recognized, but endpoint responded. Treating as reachable.');
|
|
|
|
| 323 |
Array.isArray(payload) ||
|
| 324 |
(payload && typeof payload === 'object' && ('url' in payload || 'path' in payload));
|
| 325 |
if (!ok) console.warn('⚠️ Legacy image payload not recognized, but endpoint responded. Treating as reachable.');
|
| 326 |
+
else console.log(' ✅ Legacy image endpoint responded.');
|
| 327 |
}
|
| 328 |
|
| 329 |
(async () => {
|
|
|
|
| 336 |
console.error('\nTests failed ❌:', e?.message || e);
|
| 337 |
process.exit(1);
|
| 338 |
}
|
| 339 |
+
})();
|
tests/test_connections/test_endpoints.sh
CHANGED
|
@@ -22,6 +22,12 @@ STORY_PROMPT="${1:-A short wholesome bedtime story about a friendly dragon.}"
|
|
| 22 |
VOICE="${2:-Cloée}"
|
| 23 |
IMG_PROMPT="${3:-a cute cat sticker}"
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
has_jq() { command -v jq >/dev/null 2>&1; }
|
| 26 |
pretty() { if has_jq; then jq . || cat; else cat; fi; }
|
| 27 |
|
|
@@ -41,28 +47,75 @@ strip_to_json() {
|
|
| 41 |
post_json() {
|
| 42 |
local url="$1" json="$2"
|
| 43 |
echo "POST $url" >&2
|
| 44 |
-
curl -sS -H "Content-Type: application/json"
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
# GET helper (returns BODY + trailing HTTP_STATUS line)
|
| 48 |
get_url() {
|
| 49 |
local url="$1"
|
| 50 |
echo "GET $url" >&2
|
| 51 |
-
curl -sS "$url" -w "\nHTTP_STATUS:%{http_code}"
|
| 52 |
}
|
| 53 |
|
| 54 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
try_call_api() {
|
| 56 |
local base="$1" func="$2" json="$3"
|
| 57 |
local call_url="${base%/}/gradio_api/call/$func"
|
| 58 |
-
local out status body body_json event_id get_url_full
|
| 59 |
|
| 60 |
out="$(post_json "$call_url" "$json" || true)"
|
| 61 |
status="${out##*HTTP_STATUS:}"
|
| 62 |
body="${out%HTTP_STATUS:*}"
|
| 63 |
body_json="$(printf '%s' "$body" | strip_to_json)"
|
| 64 |
|
| 65 |
-
# Always show the POST response JSON (event_id or error)
|
| 66 |
echo "---- ${func}: POST response ----"
|
| 67 |
printf '%s\n' "$body_json" | pretty
|
| 68 |
echo "--------------------------------"
|
|
@@ -73,34 +126,44 @@ try_call_api() {
|
|
| 73 |
return 1
|
| 74 |
fi
|
| 75 |
|
| 76 |
-
# Parse event_id
|
| 77 |
if has_jq; then
|
| 78 |
event_id="$(printf '%s' "$body_json" | jq -r '.event_id // .eventId // .eventID // empty' 2>/dev/null || echo "")"
|
| 79 |
else
|
| 80 |
event_id="$(printf '%s' "$body_json" | sed -n 's/.*"event_id"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')"
|
| 81 |
fi
|
| 82 |
-
|
| 83 |
if [[ -z "${event_id:-}" ]]; then
|
| 84 |
echo "-> Missing event_id in response." >&2
|
| 85 |
return 1
|
| 86 |
fi
|
| 87 |
|
|
|
|
| 88 |
get_url_full="${base%/}/gradio_api/call/$func/$event_id"
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
#
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
echo "-----------------------------"
|
| 98 |
-
|
| 99 |
-
if [[ "$get_status" != "200" ]]; then
|
| 100 |
-
echo "-> ${get_status} from $get_url_full" >&2
|
| 101 |
-
printf '%s\n' "$get_body" | head -c 500 >&2; echo >&2
|
| 102 |
return 1
|
| 103 |
fi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
echo "-> 200 OK (call API)"
|
| 106 |
return 0
|
|
|
|
| 22 |
VOICE="${2:-Cloée}"
|
| 23 |
IMG_PROMPT="${3:-a cute cat sticker}"
|
| 24 |
|
| 25 |
+
# Optional Bearer (for private Space / ZeroGPU quota)
|
| 26 |
+
AUTH_HEADER=()
|
| 27 |
+
if [[ -n "${HF_TOKEN:-}" ]]; then
|
| 28 |
+
AUTH_HEADER=(-H "Authorization: Bearer ${HF_TOKEN}")
|
| 29 |
+
fi
|
| 30 |
+
|
| 31 |
has_jq() { command -v jq >/dev/null 2>&1; }
|
| 32 |
pretty() { if has_jq; then jq . || cat; else cat; fi; }
|
| 33 |
|
|
|
|
| 47 |
post_json() {
|
| 48 |
local url="$1" json="$2"
|
| 49 |
echo "POST $url" >&2
|
| 50 |
+
curl -sS -H "Content-Type: application/json" "${AUTH_HEADER[@]}" \
|
| 51 |
+
-X POST "$url" -d "$json" -w "\nHTTP_STATUS:%{http_code}"
|
| 52 |
}
|
| 53 |
|
| 54 |
# GET helper (returns BODY + trailing HTTP_STATUS line)
|
| 55 |
get_url() {
|
| 56 |
local url="$1"
|
| 57 |
echo "GET $url" >&2
|
| 58 |
+
curl -sS "${AUTH_HEADER[@]}" "$url" -w "\nHTTP_STATUS:%{http_code}"
|
| 59 |
}
|
| 60 |
|
| 61 |
+
# --- Parse a Gradio SSE GET stream and echo the `complete` JSON object ---
|
| 62 |
+
# Returns 0 on success and prints the JSON object to stdout.
|
| 63 |
+
# Returns 1 on SSE `error` (prints details to stderr if present).
|
| 64 |
+
# Returns 2 on malformed/unknown stream.
|
| 65 |
+
parse_sse_complete() {
|
| 66 |
+
local stream_file="$1"
|
| 67 |
+
local event="" data_buf="" complete_json="" error_json=""
|
| 68 |
+
|
| 69 |
+
# Normalize CRLF in stream (if any)
|
| 70 |
+
sed -i 's/\r$//' "$stream_file"
|
| 71 |
+
|
| 72 |
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
| 73 |
+
if [[ "$line" == event:* ]]; then
|
| 74 |
+
event="${line#event: }"
|
| 75 |
+
continue
|
| 76 |
+
fi
|
| 77 |
+
if [[ "$line" == data:* ]]; then
|
| 78 |
+
local d="${line#data: }"
|
| 79 |
+
if [[ -z "$data_buf" ]]; then data_buf="$d"; else data_buf+=$'\n'"$d"; fi
|
| 80 |
+
continue
|
| 81 |
+
fi
|
| 82 |
+
# blank line = event separator
|
| 83 |
+
if [[ -z "$line" ]]; then
|
| 84 |
+
if [[ "$event" == "complete" ]]; then
|
| 85 |
+
complete_json="$data_buf"; break
|
| 86 |
+
elif [[ "$event" == "error" ]]; then
|
| 87 |
+
error_json="$data_buf"; break
|
| 88 |
+
fi
|
| 89 |
+
event=""; data_buf=""
|
| 90 |
+
fi
|
| 91 |
+
done < "$stream_file"
|
| 92 |
+
|
| 93 |
+
if [[ -n "$complete_json" ]]; then
|
| 94 |
+
printf '%s' "$complete_json"
|
| 95 |
+
return 0
|
| 96 |
+
fi
|
| 97 |
+
if [[ -n "$error_json" && "$error_json" != "null" ]]; then
|
| 98 |
+
echo "SSE error event:" >&2
|
| 99 |
+
echo "$error_json" >&2
|
| 100 |
+
return 1
|
| 101 |
+
fi
|
| 102 |
+
|
| 103 |
+
echo "SSE stream had no complete/error event. Raw stream follows:" >&2
|
| 104 |
+
cat "$stream_file" >&2
|
| 105 |
+
return 2
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# New Gradio v4/v5 call API: POST -> event_id -> GET SSE -> complete JSON
|
| 109 |
try_call_api() {
|
| 110 |
local base="$1" func="$2" json="$3"
|
| 111 |
local call_url="${base%/}/gradio_api/call/$func"
|
| 112 |
+
local out status body body_json event_id get_url_full stream_file comp_json
|
| 113 |
|
| 114 |
out="$(post_json "$call_url" "$json" || true)"
|
| 115 |
status="${out##*HTTP_STATUS:}"
|
| 116 |
body="${out%HTTP_STATUS:*}"
|
| 117 |
body_json="$(printf '%s' "$body" | strip_to_json)"
|
| 118 |
|
|
|
|
| 119 |
echo "---- ${func}: POST response ----"
|
| 120 |
printf '%s\n' "$body_json" | pretty
|
| 121 |
echo "--------------------------------"
|
|
|
|
| 126 |
return 1
|
| 127 |
fi
|
| 128 |
|
| 129 |
+
# Parse event_id
|
| 130 |
if has_jq; then
|
| 131 |
event_id="$(printf '%s' "$body_json" | jq -r '.event_id // .eventId // .eventID // empty' 2>/dev/null || echo "")"
|
| 132 |
else
|
| 133 |
event_id="$(printf '%s' "$body_json" | sed -n 's/.*"event_id"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')"
|
| 134 |
fi
|
|
|
|
| 135 |
if [[ -z "${event_id:-}" ]]; then
|
| 136 |
echo "-> Missing event_id in response." >&2
|
| 137 |
return 1
|
| 138 |
fi
|
| 139 |
|
| 140 |
+
# GET SSE
|
| 141 |
get_url_full="${base%/}/gradio_api/call/$func/$event_id"
|
| 142 |
+
stream_file="$(mktemp)"
|
| 143 |
+
echo "GET $get_url_full" >&2
|
| 144 |
+
curl -sS -N -H "Accept: text/event-stream" "${AUTH_HEADER[@]}" \
|
| 145 |
+
"$get_url_full" > "$stream_file"
|
| 146 |
+
|
| 147 |
+
# Parse SSE to the complete event JSON object
|
| 148 |
+
if ! comp_json="$(parse_sse_complete "$stream_file")"; then
|
| 149 |
+
rm -f "$stream_file"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return 1
|
| 151 |
fi
|
| 152 |
+
rm -f "$stream_file"
|
| 153 |
+
|
| 154 |
+
# Show full `complete` JSON (wrapper includes {"data":[ ... ], ...})
|
| 155 |
+
echo "---- ${func}: GET (complete event) ----"
|
| 156 |
+
printf '%s\n' "$comp_json" | pretty
|
| 157 |
+
echo "--------------------------------------"
|
| 158 |
+
|
| 159 |
+
# If this is the story function, also print ONLY the story component (.data[0])
|
| 160 |
+
if [[ "$base" == "$STORY_BASE" && "$func" == "predict" ]]; then
|
| 161 |
+
if has_jq && jq -e '.data | type == "array" and (.data | length) > 0' >/dev/null 2>&1 <<<"$comp_json"; then
|
| 162 |
+
echo "---- story data (.data[0]) ----"
|
| 163 |
+
jq -r '.data[0]' <<<"$comp_json"
|
| 164 |
+
echo "-------------------------------"
|
| 165 |
+
fi
|
| 166 |
+
fi
|
| 167 |
|
| 168 |
echo "-> 200 OK (call API)"
|
| 169 |
return 0
|
tests/test_connections/test_story.sh
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# tests/test_connections/test_story.sh
|
| 3 |
+
# One-file cURL client for ruslanmv/ai-story-server-cpu -> /predict (SSE aware)
|
| 4 |
+
# Loads .env automatically from repo root if present.
|
| 5 |
+
|
| 6 |
+
set -euo pipefail
|
| 7 |
+
|
| 8 |
+
# Optional debug
|
| 9 |
+
[[ "${DEBUG:-}" == "1" ]] && set -x
|
| 10 |
+
|
| 11 |
+
# Ensure UTF-8 (for "Cloée")
|
| 12 |
+
export LANG="${LANG:-C.UTF-8}"
|
| 13 |
+
export LC_ALL="${LC_ALL:-C.UTF-8}"
|
| 14 |
+
|
| 15 |
+
# --- requirements ---
|
| 16 |
+
for dep in curl jq; do
|
| 17 |
+
command -v "$dep" >/dev/null 2>&1 || { echo "Error: $dep is required." >&2; exit 1; }
|
| 18 |
+
done
|
| 19 |
+
|
| 20 |
+
# --- load .env from repo root (two levels up from this script) ---
|
| 21 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
| 22 |
+
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd -P)"
|
| 23 |
+
ENV_FILE="${ENV_FILE:-${REPO_ROOT}/.env}"
|
| 24 |
+
if [[ -f "$ENV_FILE" ]]; then
|
| 25 |
+
set -a
|
| 26 |
+
# shellcheck disable=SC1090
|
| 27 |
+
. "$ENV_FILE"
|
| 28 |
+
set +a
|
| 29 |
+
fi
|
| 30 |
+
|
| 31 |
+
# --- config (env > .env > defaults) ---
|
| 32 |
+
SPACE_URL="${SPACE_URL:-${AI_STORY_API_GRADIO_URL:-https://ruslanmv-ai-story-server-cpu.hf.space}}"
|
| 33 |
+
API_PATH="/gradio_api/call/predict"
|
| 34 |
+
|
| 35 |
+
SECRET="${SECRET_TOKEN:-${AI_STORY_API_SECRET_TOKEN:-secret}}"
|
| 36 |
+
PROMPT="${STORY_PROMPT:-${1:-A cozy bedtime story about a friendly dragon who learns to paint}}"
|
| 37 |
+
ROLE="${STORY_ROLE:-${2:-Cloée}}"
|
| 38 |
+
|
| 39 |
+
case "$ROLE" in
|
| 40 |
+
"Cloée"|"Julian"|"Pirate"|"Thera") ;;
|
| 41 |
+
*) echo "Error: STORY_ROLE must be one of: Cloée, Julian, Pirate, Thera" >&2; exit 1;;
|
| 42 |
+
esac
|
| 43 |
+
|
| 44 |
+
AUTH_HEADER=()
|
| 45 |
+
[[ -n "${HF_TOKEN:-}" ]] && AUTH_HEADER+=(-H "Authorization: Bearer ${HF_TOKEN}")
|
| 46 |
+
|
| 47 |
+
# --- body (order matters: [secret, prompt, role]) ---
|
| 48 |
+
POST_BODY="$(jq -cn --arg s "$SECRET" --arg p "$PROMPT" --arg r "$ROLE" '{data: [$s, $p, $r]}')"
|
| 49 |
+
|
| 50 |
+
# --- temp files ---
|
| 51 |
+
POST_OUT="$(mktemp)"
|
| 52 |
+
STREAM_OUT="$(mktemp)"
|
| 53 |
+
cleanup() { rm -f "$POST_OUT" "$STREAM_OUT"; }
|
| 54 |
+
trap cleanup EXIT
|
| 55 |
+
|
| 56 |
+
# --- 1) POST -> get event_id ---
|
| 57 |
+
HTTP_CODE="$(curl -sS -o "$POST_OUT" -w "%{http_code}" \
|
| 58 |
+
-X POST "${SPACE_URL}${API_PATH}" \
|
| 59 |
+
-H "Content-Type: application/json" "${AUTH_HEADER[@]}" \
|
| 60 |
+
-d "$POST_BODY")"
|
| 61 |
+
|
| 62 |
+
if [[ "$HTTP_CODE" != "200" ]]; then
|
| 63 |
+
echo "Error: POST returned HTTP $HTTP_CODE" >&2
|
| 64 |
+
cat "$POST_OUT" >&2
|
| 65 |
+
exit 3
|
| 66 |
+
fi
|
| 67 |
+
if ! jq -e '.event_id' "$POST_OUT" >/dev/null 2>&1; then
|
| 68 |
+
echo "Error: POST did not return JSON with .event_id" >&2
|
| 69 |
+
cat "$POST_OUT" >&2
|
| 70 |
+
exit 3
|
| 71 |
+
fi
|
| 72 |
+
EVENT_ID="$(jq -r '.event_id' "$POST_OUT")"
|
| 73 |
+
|
| 74 |
+
# --- 2) GET SSE stream (single attempt) ---
|
| 75 |
+
curl -sS -N "${SPACE_URL}${API_PATH}/${EVENT_ID}" \
|
| 76 |
+
-H "Accept: text/event-stream" "${AUTH_HEADER[@]}" > "$STREAM_OUT"
|
| 77 |
+
|
| 78 |
+
# --- 3) Parse SSE in pure bash (no awk) ---
|
| 79 |
+
event=""
|
| 80 |
+
data_buf=""
|
| 81 |
+
complete_json=""
|
| 82 |
+
error_json=""
|
| 83 |
+
|
| 84 |
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
| 85 |
+
if [[ "$line" == event:* ]]; then
|
| 86 |
+
event="${line#event: }"
|
| 87 |
+
continue
|
| 88 |
+
fi
|
| 89 |
+
if [[ "$line" == data:* ]]; then
|
| 90 |
+
d="${line#data: }"
|
| 91 |
+
if [[ -z "$data_buf" ]]; then data_buf="$d"; else data_buf+=$'\n'"$d"; fi
|
| 92 |
+
continue
|
| 93 |
+
fi
|
| 94 |
+
# blank line = event separator
|
| 95 |
+
if [[ -z "$line" ]]; then
|
| 96 |
+
if [[ "$event" == "complete" ]]; then
|
| 97 |
+
complete_json="$data_buf"; break
|
| 98 |
+
elif [[ "$event" == "error" ]]; then
|
| 99 |
+
error_json="$data_buf"; break
|
| 100 |
+
fi
|
| 101 |
+
event=""; data_buf=""
|
| 102 |
+
fi
|
| 103 |
+
done < "$STREAM_OUT"
|
| 104 |
+
|
| 105 |
+
# --- 4) Decide outcome ---
|
| 106 |
+
if [[ -n "$complete_json" ]]; then
|
| 107 |
+
if ! jq -e '.data | type == "array" and (.data | length) > 0' >/dev/null 2>&1 <<<"$complete_json"; then
|
| 108 |
+
echo "Error: Unexpected JSON from complete event:" >&2
|
| 109 |
+
echo "$complete_json" >&2
|
| 110 |
+
exit 2
|
| 111 |
+
fi
|
| 112 |
+
jq -r '.data[0]' <<<"$complete_json"
|
| 113 |
+
exit 0
|
| 114 |
+
fi
|
| 115 |
+
|
| 116 |
+
# error or nothing
|
| 117 |
+
if [[ -n "$error_json" && "$error_json" != "null" ]]; then
|
| 118 |
+
echo "Error: Server error:" >&2
|
| 119 |
+
echo "$error_json" >&2
|
| 120 |
+
else
|
| 121 |
+
echo "Error: Space returned SSE error with no details (likely ZeroGPU quota or invalid secret)." >&2
|
| 122 |
+
echo "Hints: verify SECRET, set HF_TOKEN (loaded from .env if present), or try later." >&2
|
| 123 |
+
fi
|
| 124 |
+
exit 1
|