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 →