anomalyco / anomalyco/opencode

Qwen3.8-27B + SGLang (RadixArk/MiaAI DGX Spark): System message must be at the beginning — working plugin coalesce

Open
#45,055 2 comments 0 reactions 1 assignee View on GitHub

@neriousy is already working on this.

Since Aug 25, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

OpenCode 1.18.23 still emits multiple role: "system" fragments to OpenAI-compatible backends. Serving Qwen3.8-27B with SGLang on NVIDIA DGX Spark using the stock HuggingFace chat template (strict: only messages[0] may be system) makes every agent turn fail with:

Bad Request: System message must be at the beginning.

The same OpenCode config / same short model id worked under vLLM. After switching the inference stack to the MiaAI / RadixArk SGLang-on-DGX-Spark recipe, OpenCode broke — the model weights are Qwen3.8, the engine + raw HF template are what changed.

Related: #15059, #16560, #20785, #20813 · PRs #15018, #16981, #23656.

Serving stack (keywords)

Deployed from / based on:

  • Repo: https://github.com/MiaAI-Lab/Qwen3.8-27B-SGLang-DGX-Spark
  • Cookbook: https://docs.sglang.io/cookbook/autoregressive/Qwen/Qwen3.8-27B
  • Weights (default QUANT=nvfp4): RadixArk/Qwen3.8-27B-NVFP4-BF16-LMHead
    (also: RadixArk/Qwen3.8-27B-NVFP4, Qwen/Qwen3.8-27B, Qwen/Qwen3.8-27B-FP8)
  • Docker image: lmsysorg/sglang:qwen38-27b
  • Engine: SGLang (not vLLM) — / returns "SGLang is running", /v1/modelsowned_by: "sglang"
  • Hardware: NVIDIA DGX Spark / GB10 (aarch64)
  • Spec modes in that repo: DSpark / EAGLE-MTP / DFlash2 (we hit the same template guard regardless)
  • Reasoning / tools (server): --reasoning-parser qwen3, tool parser qwen3_coder
  • Context: native 262144
  • Default served name in MiaAI scripts: SERVED_MODEL_NAME=qwen3.8-27b-sglang
  • Our OpenCode alias (intentionally shortened to match older config): API model id qwen3.8-27b → OpenCode dgx/qwen3.8-27b

Live identity probes also reported e.g. model_type: qwen3_5, architecture Qwen3_5ForConditionalGeneration, SGLang version string like 0.0.0.dev0+qwen38.27b.….

Repro

Server (template):

# OK — single system at [0]
curl …/v1/chat/completions -d '{
  "model":"qwen3.8-27b",
  "messages":[{"role":"system","content":"Be brief."},{"role":"user","content":"Say OK"}]
}'

# 400 — system not first
# "System message must be at the beginning."

# 400 — agent-like second system after tools
# [system, user, assistant+tools, tool, system, user]

OpenCode (before workaround):

opencode run -m dgx/qwen3.8-27b 'Reply with exactly: PONG'
# → Error: Bad Request: System message must be at the beginning.

Workaround that works (plugin, no OpenCode fork)

Last plugin in plugin[] coalesces via experimental hooks (in-place mutate; reassign is a no-op):

  1. experimental.chat.system.transform — join all output.system[] with \n\none fragment
  2. experimental.chat.messages.transform — merge all system/developer messages → one messages[0]

Optional model config (documents intent; may be ignored until core lands):

"systemMessage": "single"

After plugin + restart:

opencode run -m dgx/qwen3.8-27b 'Reply with exactly: PONG'
# → PONG
Plugin sketch
export default async function QwenSingleSystem() {
  const mergeSystemFragments = (system) => {
    if (!Array.isArray(system) || system.length <= 1) return
    const merged = system
      .map((s) => (typeof s === "string" ? s : String(s ?? "")))
      .map((s) => s.trim())
      .filter(Boolean)
      .join("\n\n")
    system.splice(0, system.length, merged || "")
  }

  const collapseMessageSystems = (messages) => {
    if (!Array.isArray(messages) || messages.length === 0) return
    const systemIdx = []
    for (let i = 0; i < messages.length; i++) {
      const role = messages[i]?.info?.role
      if (role === "system" || role === "developer") systemIdx.push(i)
    }
    if (systemIdx.length <= 1) {
      if (systemIdx.length === 1 && systemIdx[0] !== 0) {
        const [sys] = messages.splice(systemIdx[0], 1)
        messages.unshift(sys)
      }
      return
    }
    const texts = []
    for (const i of systemIdx) {
      for (const p of messages[i]?.parts || []) {
        if (p?.type === "text" && p.text) texts.push(String(p.text).trim())
      }
    }
    const mergedText = texts.filter(Boolean).join("\n\n")
    for (let k = systemIdx.length - 1; k >= 0; k--) messages.splice(systemIdx[k], 1)
    messages.unshift({
      info: { role: "system" },
      parts: mergedText ? [{ type: "text", text: mergedText }] : [],
    })
  }

  return {
    "experimental.chat.system.transform": async (_input, output) => {
      mergeSystemFragments(output.system)
    },
    "experimental.chat.messages.transform": async (_input, output) => {
      collapseMessageSystems(output.messages)
    },
  }
}

Ask

Please land a core equivalent (systemMessage: "single" / merge before provider send for openai-compatible / non-Anthropic), so SGLang + raw HF Qwen3.8 (RadixArk NVFP4 on DGX Spark, lmsysorg/sglang:qwen38-27b) works without a local plugin.

Happy to test nightlies against this stack.

Note

We will drop the plugin when upstream merge ships and stays stable. If this coalesce causes context loss / instability, we will revert and report.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.