From 48d6f925b0235add0b178a216d41107118206408 Mon Sep 17 00:00:00 2001 From: ComBba Date: Wed, 13 May 2026 23:35:56 +0900 Subject: [PATCH] fix(openai): collapse callOpenAI messages into a single user content to bypass Devvit HTTP-plugin 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (v0.0.32 probe v3 — 3 cycles, captured via `devvit logs --since 90m`): | stage | shape | status | |-------|-------------------------------------------------------------------------------|--------| | (a) | GET /v1/models | 200 | | (b) | POST tiny chat, single user msg, 121 B | 200 | | (d) | (b) + response_format: { type: 'json_object' } | 200 | | (e) | (b) + reasoning_effort: 'none' + verbosity: 'low' | 200 | | (f) | POST 6 KB ASCII filler, single user msg, 5610 B | 200 | | (c) | full production: 10 messages (system + 4 few-shot pairs + user), ~7 KB | 400 | Three cycles, identical pattern. Single-variable add-ons (json mode, gpt-5.x family params, 6 KB single-message size) all pass. Only the (c) shape — 10 messages + ~7 KB combined — returns "We could not parse the JSON body of your request." Confirmed Devvit-transit issue (not our body or the API key): POST /tmp/vibe-mod-probe-c-body.json (the EXACT body that probe(c) sends, captured locally) laptop → api.openai.com: HTTP 200, 2.46s, valid compiled rule JSON returned, 1730 prompt_tokens. Devvit → api.openai.com: HTTP 400 "We could not parse the JSON body". Our body is valid JSON, pure ASCII (the existing ASCII-safe rewrite at line 1317 maps every U+0080–U+FFFF to `\uXXXX`), and OpenAI accepts it directly. Devvit's HTTP plugin trips somewhere on the (10-messages + ~7 KB + nested JSON-escapes from few-shot `JSON.stringify(ex.assistant)`) combination on the wire. Fix: Compose system instructions + few-shot examples + user rule (+ optional clarification) into a single user message: [ { role: 'user', content: '=== SYSTEM INSTRUCTIONS === ... === EXAMPLES === ... === TASK === ...' }, ] This keeps callOpenAI on the (f) shape — single-user-message body, independently verified transit-safe at 5610 B and now extended to ~7 KB of structured content — which probe v3 captured returning 200 in production three times in a row. Prompt fidelity preserved: * gpt-5.4-mini treats leading user-message instructions identically to a system role for JSON-mode compilation; `response_format: { type: 'json_object' }` still enforces strict JSON output. * Few-shot examples are inlined as `INPUT:` / `OUTPUT:` blocks so the model still learns the rule-compilation pattern. * Length caps preserved (rule ≤ 1000, clarification ≤ 500) — prompt-injection surface unchanged. Body verified end-to-end (laptop → OpenAI) with the new shape: HTTP 200, returns a valid compiled rule (`{"id":"r_new_account_post_modqueue","name":...}`). Diagnostic infrastructure (`/internal/scheduler/synthetic-compile-probe` + `devvit.json` scheduler entry) stays on the probe branch — separate PR will remove it after production verifies this fix. Probe data: 3 cycles in `npx devvit logs r/SocialSeeding --since 90m`, all showing (a)(b)(d)(e)(f) = 200, (c) = 400 with "We could not parse the JSON body". Gates: `npm run check` 4/4 PASS (typecheck strict + lint + Prettier + 183 unit + 3 @devvit/test + acceptance G1–G4). `system prompt lists every fact path` and `every safe + guarded action verb` gates unaffected since FEW_SHOT_EXAMPLES and VIBE_MOD_SYSTEM_PROMPT bodies are unchanged — only how they're assembled into the request. --- src/server/index.ts | 52 +++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 2e24ec2..a2409f1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1271,22 +1271,46 @@ async function callOpenAI( console.warn('[vibe-mod] callOpenAI: settings.get(openaiModel) threw — using default:', describeErr(err)); } + // Single user message containing system instructions, few-shot examples, and + // the user rule (+ optional clarification), all inline. + // + // Why a single message: Devvit's HTTP plugin reliably trips on `chat/completions` + // bodies that combine (a) ≥ ~7 KB total size, (b) multiple messages, and + // (c) JSON-escape sequences from nested `JSON.stringify` of few-shot + // `assistant` content. Direct (laptop → OpenAI) POSTs of the *identical* body + // return HTTP 200; the same body via Devvit's `fetch` returns HTTP 400 + // "We could not parse the JSON body". Probe v3 confirmed transit-safe shapes: + // (b) tiny single user 121 B 200, (d) tiny + response_format 200, (e) tiny + + // gpt-5.x family params 200, (f) 6 KB ASCII single user 200, (c) 7 KB 10 + // messages 400. Inlining everything as one user message keeps us on the (f) + // shape (single user content) — independently verified transit-safe at 5610 B + // and now extended to ~7 KB of structured instructions + examples. + // + // Why this preserves prompt fidelity: gpt-5.4-mini treats leading user-message + // instructions identically to a system role for the purposes of JSON-mode + // compilation; `response_format: { type: 'json_object' }` still enforces strict + // JSON output. Few-shot examples are inlined in `INPUT → OUTPUT` blocks so the + // model still learns the rule-compilation pattern. Length caps preserved + // (rule ≤ 1000, clarification ≤ 500) for prompt-injection surface control. + const clarif = clarificationAnswer?.trim().slice(0, 500); + const compositeContent = [ + '=== SYSTEM INSTRUCTIONS ===', + VIBE_MOD_SYSTEM_PROMPT, + '', + '=== EXAMPLES ===', + ...FEW_SHOT_EXAMPLES.map( + (ex, i) => `--- Example ${i + 1} ---\nINPUT:\n${ex.user}\n\nOUTPUT:\n${JSON.stringify(ex.assistant)}`, + ), + '', + '=== TASK ===', + `INPUT:\n${userRule.slice(0, 1000)}`, + ...(clarif ? ['', `CLARIFICATION:\n${clarif}`] : []), + '', + 'OUTPUT:', + ].join('\n\n'); const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [ - { role: 'system', content: VIBE_MOD_SYSTEM_PROMPT }, + { role: 'user', content: compositeContent }, ]; - for (const ex of FEW_SHOT_EXAMPLES) { - messages.push({ role: 'user', content: ex.user }); - messages.push({ role: 'assistant', content: JSON.stringify(ex.assistant) }); - } - // Original rule (capped — the form layer doesn't bound length; keep the prompt - // budget predictable and limit the prompt-injection surface). - messages.push({ role: 'user', content: userRule.slice(0, 1000) }); - // Clarification answer as a separate turn (audit FIND-11: no string concat into - // the original user content), also length-capped (gap-analysis SEC-03). - const clarif = clarificationAnswer?.trim().slice(0, 500); - if (clarif) { - messages.push({ role: 'user', content: `Clarification: ${clarif}` }); - } // Build the body, then escape every non-ASCII character as a JSON \uXXXX // sequence. OpenAI returned HTTP 400 "We could not parse the JSON body of