Skip to content

Mock LLM Responder — PHP source

Generate deterministic mock LLM API responses - chat completion JSON, SSE event streams with chunk timing, and a replay curl - for testing clients without an API key.

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

<?php
// mock-llm-responder — deterministic mock LLM API responses (PHP port).
//
// Polyglot showcase port of CosmoDev's mock-llm-responder, mirrored from the
// canonical TypeScript lib (src/lib/mockLlmResponder.ts). Every output — the
// OpenAI-style chat completion JSON, the SSE event stream, and the replay
// curl — is a pure function of the spec: no clock, no unseeded randomness.
// The only "randomness" is a mulberry32 PRNG seeded from an FNV-1a hash of
// the spec, so the same spec always produces the same bytes. That is what
// makes a client-side test suite reproducible.
//
// Lengths are counted with mb_strlen (code points), which matches the
// reference lib's units for ASCII content. Functions carry an mockllm_
// prefix so the snippet drops into any project without collisions.

const MLLM_SCENARIOS = [
    'echo',
    'canned-answer',
    'streamed-lorem',
    'error-429',
    'error-500',
    'slow-chunks',
];

const MLLM_DEFAULT_MODEL = 'mock-gpt-4o-mini';
const MLLM_DEFAULT_PROMPT = 'Hello, mock model!';
const MLLM_DEFAULT_MAX_TOKENS = 64;
const MLLM_MIN_MAX_TOKENS = 1;
const MLLM_MAX_MAX_TOKENS = 4096;

/** Fixed timestamp for every mock response (2025-01-01T00:00:00Z). */
const MLLM_MOCK_EPOCH = 1735689600;

/** The canned-answer scenario always returns this text. */
const MLLM_CANNED_ANSWER =
    'This is a canned response. A mock model returns the same answer for every request, which keeps client tests deterministic.';

/** Vocabulary for the poem-ish lorem scenarios. */
const MLLM_POEM_WORDS = [
    'cosmos', 'nebula', 'quantum', 'signal', 'photon', 'drift',
    'orbit', 'vector', 'cipher', 'lumen', 'aurora', 'echo',
    'helix', 'nova', 'pulse', 'tide', 'vertex', 'zenith',
    'quasar', 'ion', 'halo', 'flux', 'prism', 'comet',
];

const MLLM_MASK32 = 0xFFFFFFFF;

/** FNV-1a 32-bit hash — turns the spec into a deterministic seed / id. */
function mockllm_hash_string(string $s): int
{
    $h = 0x811c9dc5;
    for ($i = 0, $n = strlen($s); $i < $n; $i++) {
        $h = (($h ^ ord($s[$i])) * 0x01000193) & MLLM_MASK32;
    }
    return $h;
}

/** ($a * $b) mod 2^32 without overflowing PHP's 64-bit int to a float. */
function mockllm_mul32(int $a, int $b): int
{
    $ah = $a >> 16;
    $al = $a & 0xFFFF;
    $bh = $b >> 16;
    $bl = $b & 0xFFFF;
    $mid = ((($ah * $bl) + ($al * $bh)) & 0xFFFF) << 16;
    return (($al * $bl) + $mid) & MLLM_MASK32;
}

/** mulberry32 — tiny seeded PRNG; same seed, same sequence, forever. */
function mockllm_mulberry32(int $seed): Closure
{
    $a = $seed & MLLM_MASK32;
    return function () use (&$a): float {
        $a = ($a + 0x6d2b79f5) & MLLM_MASK32;
        $t = $a;
        $t = mockllm_mul32($t ^ ($t >> 15), $t | 1);
        $t = ($t ^ ($t + mockllm_mul32($t ^ ($t >> 7), $t | 61))) & MLLM_MASK32;
        return (($t ^ ($t >> 14)) & MLLM_MASK32) / 4294967296;
    };
}

/** ~4 chars per token, floor of 1 — deterministic, no tokenizer needed. */
function mockllm_token_count(string $text): int
{
    if ($text === '') {
        return 0;
    }
    return max(1, (int) ceil(mb_strlen($text, 'UTF-8') / 4));
}

/** Cut text so it fits in $max_tokens tokens (4 chars each). */
function mockllm_truncate_to_tokens(string $text, int $max_tokens): string
{
    if (mockllm_token_count($text) <= $max_tokens) {
        return $text;
    }
    return rtrim(mb_substr($text, 0, $max_tokens * 4, 'UTF-8'));
}

/**
 * Validate + default a raw spec (an assoc array with scenario/model/
 * maxTokens/prompt keys; missing keys fall back to the defaults).
 * Unknown scenarios throw ValueError.
 * @return array{scenario:string,model:string,maxTokens:int,prompt:string}
 */
function mockllm_normalize_spec(?array $raw): array
{
    $scenario = $raw['scenario'] ?? null;
    if (!is_string($scenario) || !in_array($scenario, MLLM_SCENARIOS, true)) {
        throw new ValueError('Unknown scenario: ' . json_encode($scenario));
    }
    $model = $raw['model'] ?? null;
    $model = is_string($model) && trim($model) !== '' ? trim($model) : MLLM_DEFAULT_MODEL;
    $t = $raw['maxTokens'] ?? null;
    if (is_int($t) || (is_float($t) && is_finite($t))) {
        $maxTokens = (int) min(MLLM_MAX_MAX_TOKENS, max(MLLM_MIN_MAX_TOKENS, floor($t)));
    } else {
        $maxTokens = MLLM_DEFAULT_MAX_TOKENS;
    }
    $prompt = $raw['prompt'] ?? null;
    $prompt = is_string($prompt) && $prompt !== '' ? $prompt : MLLM_DEFAULT_PROMPT;
    return ['scenario' => $scenario, 'model' => $model, 'maxTokens' => $maxTokens, 'prompt' => $prompt];
}

function mockllm_spec_seed(array $spec, string $salt): int
{
    return mockllm_hash_string("{$spec['scenario']}|{$spec['model']}|{$spec['maxTokens']}|{$salt}");
}

function mockllm_build_id(array $spec): string
{
    return sprintf('chatcmpl-mock-%08x', mockllm_spec_seed($spec, 'id'));
}

/** One poem line of 5-7 vocabulary words. */
function mockllm_make_line(Closure $rng): string
{
    $n = 5 + (int) floor($rng() * 3);
    $words = [];
    for ($i = 0; $i < $n; $i++) {
        $words[] = MLLM_POEM_WORDS[(int) floor($rng() * count(MLLM_POEM_WORDS))];
    }
    return implode(' ', $words);
}

/** Poem-ish lorem, grown line by line until the token budget is full. */
function mockllm_build_poem(array $spec): string
{
    $rng = mockllm_mulberry32(mockllm_spec_seed($spec, 'poem'));
    $text = '';
    for (;;) {
        $line = mockllm_make_line($rng);
        $candidate = $text === '' ? $line : $text . "\n" . $line;
        if ($text !== '' && mockllm_token_count($candidate) > $spec['maxTokens']) {
            break;
        }
        $text = $candidate;
    }
    return mockllm_truncate_to_tokens($text, $spec['maxTokens']);
}

/** The assistant content a scenario produces ('' for the error scenarios). */
function mockllm_build_content(array $spec): string
{
    switch ($spec['scenario']) {
        case 'echo':
            return mockllm_truncate_to_tokens($spec['prompt'], $spec['maxTokens']);
        case 'canned-answer':
            return mockllm_truncate_to_tokens(MLLM_CANNED_ANSWER, $spec['maxTokens']);
        case 'streamed-lorem':
        case 'slow-chunks':
            return mockllm_build_poem($spec);
        default: // error-429, error-500
            return '';
    }
}

/**
 * OpenAI-style chat completion (or error envelope) for the spec.
 * @return array{ok:bool,status:int,body:array}
 */
function mockllm_build_completion(array $spec): array
{
    if ($spec['scenario'] === 'error-429') {
        return [
            'ok' => false,
            'status' => 429,
            'body' => ['error' => [
                'message' => 'Rate limit reached for the mock model. Please retry after 1 second.',
                'type' => 'rate_limit_error',
                'code' => 'rate_limit_exceeded',
            ]],
        ];
    }
    if ($spec['scenario'] === 'error-500') {
        return [
            'ok' => false,
            'status' => 500,
            'body' => ['error' => [
                'message' => 'The mock server had an error while processing your request.',
                'type' => 'server_error',
                'code' => 'internal_server_error',
            ]],
        ];
    }
    $content = mockllm_build_content($spec);
    $completionTokens = mockllm_token_count($content);
    return [
        'ok' => true,
        'status' => 200,
        'body' => [
            'id' => mockllm_build_id($spec),
            'object' => 'chat.completion',
            'created' => MLLM_MOCK_EPOCH,
            'model' => $spec['model'],
            'choices' => [[
                'index' => 0,
                'message' => ['role' => 'assistant', 'content' => $content],
                'finish_reason' => $completionTokens >= $spec['maxTokens'] ? 'length' : 'stop',
            ]],
            'usage' => [
                'prompt_tokens' => mockllm_token_count($spec['prompt']),
                'completion_tokens' => $completionTokens,
                'total_tokens' => mockllm_token_count($spec['prompt']) + $completionTokens,
            ],
        ],
    ];
}

/** True for the scenarios meant to be consumed as an SSE stream. */
function mockllm_is_stream_scenario(string $scenario): bool
{
    return $scenario === 'streamed-lorem' || $scenario === 'slow-chunks';
}

/**
 * Split content into stream chunks. Chunks reassemble to the exact content:
 * word tokens keep their trailing whitespace, so any grouping reassembles
 * the original byte for byte.
 *
 * @return list<string>
 */
function mockllm_chunk_content(array $spec): array
{
    if ($spec['scenario'] === 'error-429' || $spec['scenario'] === 'error-500') {
        return [];
    }
    $perChunk = match ($spec['scenario']) {
        'streamed-lorem' => 4,
        'slow-chunks' => 2,
        default => PHP_INT_MAX, // echo / canned-answer: one single chunk
    };
    preg_match_all('/\S+\s*/', mockllm_build_content($spec), $m);
    $tokens = $m[0];
    $chunks = [];
    for ($i = 0, $n = count($tokens); $i < $n; $i += $perChunk) {
        $chunks[] = implode('', array_slice($tokens, $i, $perChunk));
    }
    return $chunks;
}

/** Inter-chunk delay the stub should sleep between chunks, in ms. */
function mockllm_chunk_delay_ms(array $spec): int
{
    return match ($spec['scenario']) {
        'echo' => 25,
        'canned-answer' => 120,
        'streamed-lorem' => 40,
        'slow-chunks' => 600,
        default => 0,
    };
}

/** Time-to-first-byte the stub should sleep before the first event, in ms. */
function mockllm_first_byte_ms(array $spec): int
{
    return match ($spec['scenario']) {
        'echo' => 20,
        'canned-answer' => 350,
        'streamed-lorem' => 60,
        'slow-chunks' => 900,
        default => 0,
    };
}

/** Compact JSON with JSON.stringify semantics (no escaped slashes/unicode). */
function mockllm_json(array|stdClass $x): string
{
    return json_encode($x, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

/** The SSE event stream: `data:` lines, timing comment markers, [DONE]. */
function mockllm_build_sse(array $spec): string
{
    $res = mockllm_build_completion($spec);
    $lines = [
        sprintf(
            ': mock scenario=%s first-byte=%dms inter-chunk=%dms',
            $spec['scenario'],
            mockllm_first_byte_ms($spec),
            mockllm_chunk_delay_ms($spec)
        ),
        '',
    ];
    if (!$res['ok']) {
        $lines[] = 'data: ' . mockllm_json($res['body']);
        $lines[] = '';
    } else {
        foreach (mockllm_chunk_content($spec) as $i => $chunk) {
            $delta = $i === 0
                ? ['role' => 'assistant', 'content' => $chunk]
                : ['content' => $chunk];
            $lines[] = 'data: ' . mockllm_json([
                'id' => mockllm_build_id($spec),
                'object' => 'chat.completion.chunk',
                'created' => MLLM_MOCK_EPOCH,
                'model' => $spec['model'],
                'choices' => [['index' => 0, 'delta' => $delta, 'finish_reason' => null]],
            ]);
            $lines[] = '';
        }
        $body = $res['body'];
        $lines[] = 'data: ' . mockllm_json([
            'id' => mockllm_build_id($spec),
            'object' => 'chat.completion.chunk',
            'created' => MLLM_MOCK_EPOCH,
            'model' => $spec['model'],
            'choices' => [[
                'index' => 0,
                'delta' => new stdClass(),
                'finish_reason' => $body['choices'][0]['finish_reason'],
            ]],
            'usage' => $body['usage'],
        ]);
        $lines[] = '';
    }
    $lines[] = 'data: [DONE]';
    $lines[] = '';
    return implode("\n", $lines);
}

/** A curl command that replays the request against a local stub on :8080. */
function mockllm_build_curl(array $spec): string
{
    $status = mockllm_build_completion($spec)['status'];
    $body = [
        'model' => $spec['model'],
        'messages' => [['role' => 'user', 'content' => $spec['prompt']]],
        'max_tokens' => $spec['maxTokens'],
    ];
    $stream = mockllm_is_stream_scenario($spec['scenario']);
    if ($stream) {
        $body['stream'] = true;
    }
    return implode("\n", [
        "# Local stub: reply {$status} with the body shown in the JSON tab.",
        'curl ' . ($stream ? '-N -s' : '-s') . ' http://localhost:8080/v1/chat/completions \\',
        "  -H 'Content-Type: application/json' \\",
        "  -d '" . mockllm_json($body) . "'",
    ]);
}

/** One canonical demo line — handy for diffing against the other ports. */
function mockllm_demo_line(array $spec): string
{
    $res = mockllm_build_completion($spec);
    $out = [
        'spec' => "{$spec['scenario']}|{$spec['model']}|{$spec['maxTokens']}|{$spec['prompt']}",
        'status' => $res['status'],
        'id' => '',
        'finish' => '',
        'pt' => 0,
        'ct' => 0,
        'tt' => 0,
        'chunks' => 0,
        'content' => '',
        'err' => '',
        'curl' => mockllm_build_curl($spec),
        'sse' => mockllm_build_sse($spec),
    ];
    if ($res['ok']) {
        $choice = $res['body']['choices'][0];
        $out['id'] = $res['body']['id'];
        $out['finish'] = $choice['finish_reason'];
        $out['pt'] = $res['body']['usage']['prompt_tokens'];
        $out['ct'] = $res['body']['usage']['completion_tokens'];
        $out['tt'] = $res['body']['usage']['total_tokens'];
        $out['content'] = $choice['message']['content'];
        $out['chunks'] = count(mockllm_chunk_content($spec));
    } else {
        $out['err'] = $res['body']['error']['code'];
    }
    return mockllm_json($out);
}

// Demo: run this file directly (`php php.php`) to print one canonical JSON
// line per scenario — handy for diffing against the other ports.
if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) {
    foreach ([
        ['scenario' => 'echo'],
        ['scenario' => 'canned-answer', 'maxTokens' => 10],
        ['scenario' => 'streamed-lorem', 'maxTokens' => 40],
        ['scenario' => 'slow-chunks', 'maxTokens' => 30],
        ['scenario' => 'error-429'],
        ['scenario' => 'error-500'],
        ['scenario' => 'echo', 'model' => 'my-model "x"', 'maxTokens' => 8, 'prompt' => "Say \"hi\"\nline"],
    ] as $raw) {
        echo mockllm_demo_line(mockllm_normalize_spec($raw)), PHP_EOL;
    }
}

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 →