Last verified November 2026. Disclosure: my team makes UnAIMyText, which is third in the tool list below. The Java is free to copy.

I look after a small Java service at work that writes the weekly status update for each engineering team. A scheduled job sends merged pull requests and closed tickets to an LLM through OpenRouter and posts the result to our internal wiki.

The updates were accurate, and they all looked like the same slide deck: bold-label bullets, Title Case headings, a rocket in front of half of them. Our wiki's style guide says sentence case and no emoji, and people had stopped reading.

So I wrote a linter. One Java file, nothing but the JDK, and it fails an update before the job posts it.

What the linter checks

My earlier checkers in this series looked at wording and punctuation. This one looks at Markdown structure:

  1. Bold-label bullets. - **Faster Builds:** CI now caches... once is fine. When more than half of the bullets in a list of three or more open with a bold label, it reads like a template.
  2. Emoji. More than one emoji fails. The count skips fenced code and inline code, so a deploy script that prints a rocket doesn't count.
  3. Title Case headings. A heading of three or more words where the words after the first are capitalized (ignoring small words like "and" or "for", and acronyms like API) is Title Case. More than one fails, because our house style is sentence case.

The exit code is 0 for a pass, 1 for a rewrite and 2 when the API call fails. --json prints a machine-readable report.

The request

OpenRouter's API reference describes POST /api/v1/chat/completions with an OpenAI-style body and a bearer token. A few details from it shaped the code:

  • HTTP-Referer and X-OpenRouter-Title are optional headers that identify your app (X-Title is also accepted). The linter only sends them if you set OPENROUTER_APP_URL.
  • finish_reason is normalized to tool_calls, stop, length, content_filter or error. Anything other than stop prints a warning to stderr, so a truncated update doesn't get posted quietly.
  • A choice can carry its own error object, and message.content can be null. Both count as a failed request.

The default model is openai/gpt-oss-20b, which is a reasoning model. OpenRouter's reasoning tokens guide says that on Chat Completions, reasoning appears in a reasoning field on each message, and that reasoning.exclude omits it from the response (the tokens are still billed). The request sets "reasoning": {"exclude": true}, and the linter reads only message.content either way, because that's what gets posted.

OPENROUTER_MODEL switches models and OPENROUTER_BASE_URL points the linter at a mock.

The code

Java 17 or newer. Run it straight from source with java StatusLint.java; there's no build step.

import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; /** Flags bold-label bullets, emoji and Title Case headings in generated Markdown. */ public class StatusLint { // House style knobs. static final int MIN_BULLETS = 3; static final double MAX_BOLD_SHARE = 0.5; static final int MAX_EMOJI = 1; static final int MAX_TITLE_CASE_HEADINGS = 1; static final Pattern FENCE = Pattern.compile("(?ms)^``` .*?^ ```[ \\t]*$"); static final Pattern HEADING = Pattern.compile("^\\s{0,3}#{1,6}\\s+(.+?)\\s*#*\\s*$"); static final Pattern BULLET = Pattern.compile("^\\s*(?:[-*+]|\\d+[.)])\\s+(.*)$"); static final Pattern BOLD_LABEL = Pattern.compile("^(?:\\*\\*|__)[^*_]{1,40}(?::(?:\\*\\*|__)|(?:\\*\\*|__):)"); static final Pattern SMALL_WORDS = Pattern.compile( "a|an|and|as|at|but|by|for|in|nor|of|on|or|the|to|vs|via|with"); record Result(int bullets, int boldBullets, int emoji, int headings, List<String> titleCaseHeadings, List<String> reasons) {} static Result check(String markdown) { String text = FENCE.matcher(markdown).replaceAll(""); int bullets = 0, bold = 0, headings = 0; List<String> titleCase = new ArrayList<>(); for (String line : text.split("\\R")) { var h = HEADING.matcher(line); if (h.matches()) { headings++; if (isTitleCase(h.group(1))) titleCase.add(h.group(1)); continue; } var b = BULLET.matcher(line); if (b.matches()) { bullets++; if (BOLD_LABEL.matcher(b.group(1)).find()) bold++; } } String noInlineCode = text.replaceAll("`[^`\\n]*`", ""); int emoji = (int) noInlineCode.codePoints().filter(StatusLint::isEmoji).count(); List<String> reasons = new ArrayList<>(); if (bullets >= MIN_BULLETS && bold > bullets * MAX_BOLD_SHARE) reasons.add("bold-label bullets"); if (emoji > MAX_EMOJI) reasons.add("emoji"); if (titleCase.size() > MAX_TITLE_CASE_HEADINGS) reasons.add("Title Case headings"); return new Result(bullets, bold, emoji, headings, titleCase, reasons); } /** Title Case = at least two capitalized words after the first, and no lowercase word that isn't small. */ static boolean isTitleCase(String heading) { String[] words = heading.replaceAll("[^\\p{L}\\p{N}'\\s-]", " ").trim().split("\\s+"); if (words.length < 3) return false; int capped = 0; for (int i = 1; i < words.length; i++) { String w = words[i]; if (w.isEmpty() || !Character.isLetter(w.charAt(0))) continue; if (Character.isUpperCase(w.charAt(0))) { if (!w.equals(w.toUpperCase())) capped++; // skip acronyms like API } else if (!SMALL_WORDS.matcher(w).matches()) { return false; } } return capped >= 2; } static boolean isEmoji(int cp) { return (cp >= 0x1F300 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0x1F1E6 && cp <= 0x1F1FF) || cp == 0x2B50 || cp == 0x2705; } static String askOpenRouter(String prompt, String key) throws IOException, InterruptedException { String base = System.getenv().getOrDefault("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"); String model = System.getenv().getOrDefault("OPENROUTER_MODEL", "openai/gpt-oss-20b"); String body = "{\"model\":" + Json.quote(model) + ",\"messages\":[{\"role\":\"user\",\"content\":" + Json.quote(prompt) + "}]" + ",\"reasoning\":{\"exclude\":true}}"; // keep thinking out of the reply var req = HttpRequest.newBuilder(URI.create(base.replaceAll("/+$", "") + "/chat/completions")) .timeout(Duration.ofSeconds(60)) .header("Authorization", "Bearer " + key) .header("Content-Type", "application/json"); String appUrl = System.getenv("OPENROUTER_APP_URL"); // optional attribution if (appUrl != null) req.header("HTTP-Referer", appUrl).header("X-OpenRouter-Title", "statuslint"); HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); HttpResponse<String> res = client.send(req.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (res.statusCode() / 100 != 2) { String snippet = res.body().substring(0, Math.min(200, res.body().length())); throw new IOException("HTTP " + res.statusCode() + ": " + snippet); } Object choices = Json.get(Json.parse(res.body()), "choices"); if (!(choices instanceof List<?> list) || list.isEmpty()) throw new IOException("no choices in reply"); Object choice = list.get(0); if (Json.get(choice, "error") != null) throw new IOException("provider error: " + Json.get(choice, "error")); Object finish = Json.get(choice, "finish_reason"); if (finish != null && !"stop".equals(finish)) System.err.println("warning: finish_reason=" + finish); // Only message.content is checked; a "reasoning" field, if any, is ignored. Object content = Json.get(Json.get(choice, "message"), "content"); if (!(content instanceof String s) || s.isBlank()) throw new IOException("reply had no content"); return s; } public static void main(String[] args) throws IOException { List<String> rest = new ArrayList<>(List.of(args)); boolean asJson = rest.remove("--json"); String prompt = String.join(" ", rest).trim(); String key = System.getenv().getOrDefault("OPENROUTER_API_KEY", ""); String raw; try { raw = key.isEmpty() || prompt.isEmpty() ? new String(System.in.readAllBytes(), StandardCharsets.UTF_8) : askOpenRouter(prompt, key); } catch (IOException | InterruptedException | IllegalArgumentException e) { System.err.println("request failed: " + e); System.exit(2); return; } Result r = check(raw); if (asJson) { System.out.println(Json.write(Map.of("bullets", r.bullets(), "bold_bullets", r.boldBullets(), "emoji", r.emoji(), "headings", r.headings(), "title_case_headings", r.titleCaseHeadings(), "reasons", r.reasons()))); } else { System.out.printf("bullets: %d (%d bold-label), emoji: %d, headings: %d (%d Title Case)%n", r.bullets(), r.boldBullets(), r.emoji(), r.headings(), r.titleCaseHeadings().size()); System.out.println(r.reasons().isEmpty() ? "verdict: ok" : "verdict: rewrite (" + String.join("; ", r.reasons()) + ")"); } System.exit(r.reasons().isEmpty() ? 0 : 1); } /** Just enough JSON for one request and one response; no dependencies. */ static final class Json { private final String s; private int i; private Json(String s) { this.s = s; } static Object parse(String text) { Json p = new Json(text); try { Object v = p.value(); p.ws(); if (p.i != text.length()) throw new IllegalArgumentException("trailing data at " + p.i); return v; } catch (IndexOutOfBoundsException e) { throw new IllegalArgumentException("truncated JSON"); } } static Object get(Object node, String key) { return node instanceof Map<?, ?> m ? m.get(key) : null; } static String quote(String v) { StringBuilder b = new StringBuilder("\""); for (char c : v.toCharArray()) { switch (c) { case '"' -> b.append("\\\""); case '\\' -> b.append("\\\\"); case '\n' -> b.append("\\n"); case '\r' -> b.append("\\r"); case '\t' -> b.append("\\t"); default -> b.append(c < 0x20 ? String.format("\\u%04x", (int) c) : String.valueOf(c)); } } return b.append('"').toString(); } static String write(Object v) { if (v instanceof String str) return quote(str); if (v instanceof Map<?, ?> m) { List<String> parts = new ArrayList<>(); new java.util.TreeMap<>(m).forEach((k, val) -> parts.add(quote(k.toString()) + ":" + write(val))); return "{" + String.join(",", parts) + "}"; } if (v instanceof List<?> l) { return "[" + String.join(",", l.stream().map(Json::write).toList()) + "]"; } return String.valueOf(v); } private void ws() { while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++; } private Object value() { ws(); if (i >= s.length()) throw new IllegalArgumentException("unexpected end of JSON"Source:https://dev.to/unaimytext_app/humanize-openrouter-output-in-java-a-zero-dependency-linter-for-bold-label-bullets-emoji-and-1b71

Related posts