Skip to content

Context Window Planner — C# source

Paste your system prompt, docs, and history — see how they fill any model's context window, with overflow warnings and output headroom.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Context Window Planner — plan labeled prompt sections against a model's
// context window.
//
// Language: C# (C# 12 / .NET 8, base class library only)
// Source:   CosmoDev polyglot showcase port of the Context Window Planner
//           tool, ported from src/lib/contextPlanner.ts (the canonical
//           TypeScript implementation).
// Live at:  https://dev.cosmolabs.org/tools/context-window-planner
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; never throws (public API returns plain values).
//   - Functionally equivalent to the TS reference: same inputs -> same outputs.
//   - Self-contained: BCL only. (System.Text.Json would be the stdlib
//     equivalent of serde_json; this port instead ships the same small strict
//     recursive-descent validator the other polyglot siblings use, so the
//     JSON grammar matches JSON.parse exactly rather than approximately.)
//
// Port notes: the TS lib delegates to two siblings — `estimateTokens` from
// src/lib/tokenEstimator.ts and `fitsWindow` from src/lib/ai/models.ts (which
// defaults to the bundled pricing snapshot, src/data/ai-models.json). A
// dependency-free port cannot load that file, so the estimator is inlined
// below in the exact form the planner uses it (`estimateTokens(text).tokens`,
// auto content type — the full heuristic lives in the token-estimator port),
// window math is inlined from `fitsWindow` and `models` is an explicit
// parameter, never re-derived.
//
// Faithfulness notes (the places C#'s runtime silently differs from JS):
//   - Length: TS's `String.length` counts UTF-16 code units — and so does
//     C#'s `string.Length`, so no helper is needed; astral-plane characters
//     (emoji, rare CJK ext-B ideographs) already count as 2 on both sides.
//   - Rounding: `MidpointRounding.AwayFromZero` on non-negative values is
//     exactly JS `Math.round` (halfway cases up); the helper states it.

using System;
using System.Collections.Generic;
using System.Globalization;

namespace CosmoDev.ContextWindowPlanner;

/// One labeled block of the prompt (system / docs / history / ...).
/// Mirrors the TS <c>PlanSection</c> interface.
public sealed record PlanSection(string Label, string Text);

/// The subset of the TS <c>AiModel</c> record the planner reads.
/// Production code passes the full snapshot entry; only these fields
/// influence the plan.
public sealed record Model(string Id, long ContextWindow, long MaxOutput);

/// Result of <see cref="Planner.PlanWindow"/>. Field-for-field twin of the
/// TS <c>WindowPlan</c> interface.
public sealed record WindowPlan(
    string Id,
    long InputTokens,
    long ContextWindow,
    long Free,
    bool Fits,
    bool OutputReserveOk,
    long MaxOutput);

public static class Planner
{
    /// Sample table for standalone use (mirrors the shared test fixtures).
    /// Production code passes the model snapshot instead.
    public static readonly IReadOnlyList<Model> SampleModels = new[] {
        new Model("alpha-mini", 200_000, 10_000),
        new Model("beta-pro", 1_000_000, 10_000),
        new Model("gamma-open", 100_000, 10_000),
    };

    /// Content classification of a single line. The planner only needs each
    /// type's chars-per-token rate (mirrors <c>CHARS_PER_TOKEN</c> in
    /// src/lib/tokenEstimator.ts: prose 4, code 3.5, json 3, cjk 1.5).
    private enum ContentType { Prose, Code, Json, Cjk }

    private static double CharsPerToken(ContentType t) => t switch
    {
        ContentType.Prose => 4.0,
        ContentType.Code => 3.5,
        ContentType.Json => 3.0,
        ContentType.Cjk => 1.5,
        _ => 4.0,
    };

    /// Reports whether <paramref name="s"/> contains a CJK ideograph
    /// (U+4E00–U+9FFF), kana (U+3040–U+30FF), or a Hangul syllable
    /// (U+AC00–U+D7AF). Mirrors <c>CJK_RE</c> in the TS lib.
    private static bool HasCjk(string s)
    {
        foreach (char c in s)
        {
            if ((c >= '一' && c <= '鿿') ||
                (c >= '぀' && c <= 'ヿ') ||
                (c >= '가' && c <= '힯'))
                return true;
        }
        return false;
    }

    /// Reports whether <paramref name="c"/> is one of the code-flavored
    /// symbols counted by <c>CODE_SYMBOL_RE</c> (<c>{}();&lt;&gt;[]#</c>).
    private static bool IsCodeSymbol(char c) =>
        c is '{' or '}' or '(' or ')' or ';' or '=' or '<' or '>' or '[' or ']' or '#';

    /// JS <c>Math.round</c>: halfway cases round up
    /// (<c>AwayFromZero</c> over the non-negative inputs used here).
    private static long JsRound(double x) =>
        (long)Math.Round(x, MidpointRounding.AwayFromZero);

    /// Splits <paramref name="text"/> on LF or CRLF, mirroring
    /// <c>text.split(/\r?\n/)</c>: strip the optional CR that belongs to the
    /// newline, then split on LF. A lone CR is NOT a line break.
    private static string[] SplitLines(string text) => text.Split('\n');

    /// Classifies a single line by its shape. Order: json, cjk, code, prose.
    /// Inlined from <c>detectLineType()</c> in src/lib/tokenEstimator.ts.
    private static ContentType DetectLineType(string line)
    {
        string trimmed = line.Trim();
        // JSON-ish: opens like a JSON fragment AND carries a separator.
        bool startsJsonish = trimmed.StartsWith('{') || trimmed.StartsWith('}') ||
                             trimmed.StartsWith('[') || trimmed.StartsWith('"');
        if (startsJsonish && (line.Contains(':') || line.Contains(',')))
            return ContentType.Json;
        // CJK ideographs / kana / Hangul pack roughly one token per 1.5 chars.
        if (HasCjk(line))
            return ContentType.Cjk;
        // Code: symbol-dense, or a statement terminator / block opener at EOL.
        int length = line.Length; // UTF-16 code units, same unit as TS
        int symbols = 0;
        foreach (char c in line)
            if (IsCodeSymbol(c)) symbols++;
        double density = length > 0 ? (double)symbols / length : 0.0;
        if (density > 0.08 || trimmed.EndsWith(";") || trimmed.EndsWith("{") ||
            trimmed.EndsWith("}"))
            return ContentType.Code;
        return ContentType.Prose;
    }

    /// A strict JSON syntax validator — the exact grammar <c>JSON.parse</c>
    /// accepts, walked with a cursor. Same shape as the Rust/C/C++
    /// siblings' validators, so whole-text JSON detection behaves
    /// identically across every port.
    private sealed class JsonParser
    {
        private readonly string s;
        private int pos;

        internal JsonParser(string text) => s = text;

        /// value := ws* (object | array | string | number | 'true' | 'false' | 'null') ws*
        internal bool Value()
        {
            SkipWs();
            return Peek() switch
            {
                '{' => Object(),
                '[' => Array(),
                '"' => String(),
                '-' or (>= '0' and <= '9') => Number(),
                't' => Literal("true"),
                'f' => Literal("false"),
                'n' => Literal("null"),
                _ => false,
            };
        }

        internal bool AtEnd()
        {
            SkipWs();
            return pos == s.Length; // reject trailing garbage
        }

        private void SkipWs()
        {
            while (pos < s.Length &&
                   (s[pos] == ' ' || s[pos] == '\t' || s[pos] == '\n' || s[pos] == '\r'))
                pos++;
        }

        private char Peek() => pos < s.Length ? s[pos] : '\0';

        private bool Eat(char b)
        {
            if (Peek() == b && pos < s.Length) { pos++; return true; }
            return false;
        }

        private bool Literal(string lit)
        {
            if (s.AsSpan(pos).StartsWith(lit))
            {
                pos += lit.Length;
                return true;
            }
            return false;
        }

        /// object := '{' ws* (string ws* ':' value (ws* ',' ...)*)? ws* '}'
        private bool Object()
        {
            if (!Eat('{')) return false;
            SkipWs();
            if (Eat('}')) return true;
            while (true)
            {
                if (!String()) return false;
                SkipWs();
                if (!Eat(':')) return false;
                if (!Value()) return false;
                SkipWs();
                if (Eat(',')) SkipWs();
                else return Eat('}');
            }
        }

        /// array := '[' ws* (value (ws* ',' ws* value)*)? ws* ']'
        private bool Array()
        {
            if (!Eat('[')) return false;
            SkipWs();
            if (Eat(']')) return true;
            while (true)
            {
                if (!Value()) return false;
                SkipWs();
                if (Eat(',')) SkipWs();
                else return Eat(']');
            }
        }

        /// string := '"' (escape | any char >= 0x20)* '"'
        /// escape := '\' ('"' | '/' | '\' | 'b' | 'f' | 'n' | 'r' | 't' | 'u' hex4)
        private bool String()
        {
            if (!Eat('"')) return false;
            while (pos < s.Length)
            {
                char c = s[pos];
                if (c == '"') { pos++; return true; }
                if (c == '\\')
                {
                    pos++;
                    if (pos >= s.Length) return false;
                    char esc = s[pos++];
                    switch (esc)
                    {
                        case '"' or '/' or '\\' or 'b' or 'f' or 'n' or 'r' or 't':
                            break;
                        case 'u':
                            for (int i = 0; i < 4; i++)
                            {
                                char h = Peek();
                                bool hex = (h >= '0' && h <= '9') ||
                                           (h >= 'a' && h <= 'f') ||
                                           (h >= 'A' && h <= 'F');
                                if (!hex) return false;
                                pos++;
                            }
                            break;
                        default:
                            return false;
                    }
                }
                else if (c < 0x20)
                {
                    return false; // raw control characters not allowed in strings
                }
                else
                {
                    pos++;
                }
            }
            return false; // unterminated string
        }

        /// number := '-'? int frac? exp? — no leading zeros, like JSON.parse.
        private bool Number()
        {
            Eat('-');
            if (Peek() == '0') pos++;
            else if (Peek() >= '1' && Peek() <= '9')
            {
                while (Peek() >= '0' && Peek() <= '9') pos++;
            }
            else return false;
            if (Peek() == '.')
            {
                pos++;
                int digits = 0;
                while (Peek() >= '0' && Peek() <= '9') { pos++; digits++; }
                if (digits == 0) return false;
            }
            if (Peek() == 'e' || Peek() == 'E')
            {
                pos++;
                if (Peek() == '+' || Peek() == '-') pos++;
                int digits = 0;
                while (Peek() >= '0' && Peek() <= '9') { pos++; digits++; }
                if (digits == 0) return false;
            }
            return true;
        }
    }

    /// Whole-text JSON gate: a document that parses as JSON is json all the
    /// way down. Mirrors <c>isValidJson()</c> (<c>JSON.parse</c> in a
    /// try/catch); empty/whitespace text is not.
    private static bool IsValidJson(string text)
    {
        if (string.IsNullOrWhiteSpace(text)) return false;
        var p = new JsonParser(text);
        return p.Value() && p.AtEnd();
    }

    /// Token count of <paramref name="text"/> under auto content detection —
    /// exactly the slice of <c>estimateTokens()</c> the planner consumes
    /// (<c>.tokens</c>): per non-empty line,
    /// <c>max(1, round(utf16Len / charsPerToken))</c>. Framing tokens are the
    /// caller's job.
    private static long EstimateTokens(string text)
    {
        // AUTO + whole-text JSON: json's 3 chars/token rate applies to every
        // line, not just the reported content type.
        bool wholeTextJson = IsValidJson(text);
        long tokens = 0;
        foreach (string rawLine in SplitLines(text))
        {
            string line = rawLine.EndsWith("\r") ? rawLine[..^1] : rawLine;
            if (line.Trim().Length == 0) continue;
            ContentType t = wholeTextJson ? ContentType.Json : DetectLineType(line);
            long lineTokens = JsRound(line.Length / CharsPerToken(t));
            tokens += Math.Max(1, lineTokens);
        }
        return tokens;
    }

    /// Sum of per-section token estimates (framing tokens are the caller's
    /// job). Mirrors <c>inputTokenTotal()</c> in the TS lib.
    public static long InputTokenTotal(IEnumerable<PlanSection> sections)
    {
        long total = 0;
        foreach (PlanSection s in sections) total += EstimateTokens(s.Text);
        return total;
    }

    /// Plan one section set against one model's context window. Returns
    /// <c>null</c> for an unknown model id (window math is
    /// <c>fitsWindow</c>'s, never re-derived). Mirrors <c>planWindow()</c>
    /// in the TS lib.
    public static WindowPlan? PlanWindow(
        IReadOnlyList<PlanSection> sections, string modelId,
        long outputReserve = 0, IReadOnlyList<Model>? models = null)
    {
        models ??= Array.Empty<Model>();
        long inputTokens = InputTokenTotal(sections);
        // Fit check inlined from fitsWindow() in src/lib/ai/models.ts.
        Model? m = null;
        foreach (Model cand in models)
            if (cand.Id == modelId) { m = cand; break; }
        if (m is null) return null;
        long free = m.ContextWindow - inputTokens;
        return new WindowPlan(
            Id: modelId,
            InputTokens: inputTokens,
            ContextWindow: m.ContextWindow,
            Free: free,
            Fits: free >= 0,
            OutputReserveOk: free >= outputReserve,
            MaxOutput: m.MaxOutput);
    }

    /// Plan against several models; unknown ids are dropped from the result.
    /// Mirrors <c>planAll()</c> in the TS lib.
    public static List<WindowPlan> PlanAll(
        IReadOnlyList<PlanSection> sections, IReadOnlyList<string> modelIds,
        long outputReserve = 0, IReadOnlyList<Model>? models = null)
    {
        var plans = new List<WindowPlan>();
        foreach (string id in modelIds)
        {
            WindowPlan? plan = PlanWindow(sections, id, outputReserve, models);
            if (plan is not null) plans.Add(plan);
        }
        return plans;
    }
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
// Run: dotnet run --property:DefineConstants=CWP_TEST
#if CWP_TEST
public static class PlannerTests
{
    // A 1600-char single line of 'a' is pure prose: 1600 / 4 = 400 tokens.
    private static readonly string LineA = new('a', 1600);

    private static PlanSection[] TwoSections() =>
        new[] { new PlanSection("sys", LineA), new PlanSection("docs", LineA) };

    public static int Run()
    {
        // input totals
        Assert(Planner.InputTokenTotal(TwoSections()) == 800);
        Assert(Planner.InputTokenTotal(Array.Empty<PlanSection>()) == 0);
        Assert(Planner.InputTokenTotal(new[] { new PlanSection("sys", "") }) == 0);

        // plans two 400-token sections against beta-pro
        WindowPlan p = Planner.PlanWindow(TwoSections(), "beta-pro", 0, Planner.SampleModels)!;
        Assert(p.Id == "beta-pro");
        Assert(p.InputTokens == 800);
        Assert(p.ContextWindow == 1_000_000);
        Assert(p.Free == 999_200);
        Assert(p.Fits);
        Assert(p.OutputReserveOk);
        Assert(p.MaxOutput == 10_000);

        // reserve larger than free leaves raw fit true
        p = Planner.PlanWindow(TwoSections(), "beta-pro", 1_000_000, Planner.SampleModels)!;
        Assert(p.Fits);
        Assert(!p.OutputReserveOk);

        // reserve exactly equal to free is ok
        p = Planner.PlanWindow(TwoSections(), "beta-pro", 999_200, Planner.SampleModels)!;
        Assert(p.OutputReserveOk);

        // smaller window leaves 199,200 free
        p = Planner.PlanWindow(TwoSections(), "alpha-mini", 0, Planner.SampleModels)!;
        Assert(p.ContextWindow == 200_000);
        Assert(p.Free == 199_200);
        Assert(p.Fits);

        // unknown model id returns null
        Assert(Planner.PlanWindow(TwoSections(), "ghost", 0, Planner.SampleModels) is null);

        // no sections: full window free
        p = Planner.PlanWindow(Array.Empty<PlanSection>(), "beta-pro", 0, Planner.SampleModels)!;
        Assert(p.InputTokens == 0);
        Assert(p.Free == 1_000_000);
        Assert(p.Fits);

        // overflow: fits false, reserve false
        var big = new[] { new PlanSection("big", new string('z', 4_400_000)) };
        p = Planner.PlanWindow(big, "beta-pro", 0, Planner.SampleModels)!;
        Assert(p.InputTokens == 1_100_000);
        Assert(p.Free == -100_000);
        Assert(!p.Fits);
        Assert(!p.OutputReserveOk);

        // plan_all drops unknown ids and keeps order
        var plans = Planner.PlanAll(TwoSections(),
            new[] { "beta-pro", "alpha-mini", "ghost" }, 0, Planner.SampleModels);
        Assert(plans.Count == 2);
        Assert(plans[0].Id == "beta-pro");
        Assert(plans[1].Id == "alpha-mini");
        Assert(plans[1].Free == 199_200);
        Assert(Planner.PlanAll(TwoSections(), Array.Empty<string>(), 0,
            Planner.SampleModels).Count == 0);

        Console.WriteLine("context-window-planner (C#): all tests passed");
        return 0;
    }

    private static void Assert(bool cond) =>
        System.Diagnostics.Debug.Assert(cond);
}
#endif

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →