PHP
1. Add this file as api/conversation.php
<?php
/*
────────────────────────────────────────────────────────────────────────────
Code Chat v2 (chatEngine: 'v2')
Handles a v2 request: the conversation is passed through to your AI provider
and the model's reply is returned as-is.
────────────────────────────────────────────────────────────────────────────
*/
/**
* Pass the RAW request body here, not a decoded array.
*
* json_decode($json, true) turns an empty JSON object into an empty PHP array,
* which re-encodes as [] and makes the provider reject the request. Decoding
* to objects here avoids that.
*/
/*
Marks the end of the conversation as cacheable, so the next call in the same
Code Chat request re-uses everything sent so far instead of paying for it again.
*/
function cb_mark_conversation_cacheable($messages) {
for ($i = count($messages) - 1; $i >= 0; $i--) {
$m = $messages[$i];
if (!is_object($m) || !isset($m->content)) continue;
if (is_array($m->content)) {
$last = end($m->content);
if (is_object($last) && isset($last->type) && $last->type === 'text') {
$last->cache_control = (object) ['type' => 'ephemeral'];
return $messages;
}
continue;
}
if (is_string($m->content) && $m->content !== '') {
$block = new stdClass();
$block->type = 'text';
$block->text = $m->content;
$block->cache_control = (object) ['type' => 'ephemeral'];
$m->content = [$block];
return $messages;
}
}
return $messages;
}
/*
A chunk can end mid-line. Hold the remainder until the rest arrives, or the
line is echoed truncated and its tail is dropped — which corrupts long values
such as the HTML in a tool call.
*/
function cb_forward_sse_chunk($chunk, &$pending) {
$pending .= $chunk;
$lines = explode("\n", $pending);
$pending = array_pop($lines);
foreach ($lines as $line) {
if (strpos(ltrim($line), 'data:') === 0) {
echo $line . "\n";
flush();
}
}
return strlen($chunk);
}
function cb_serve_conversation($rawBody, $url, $apiKey, $defaultModel) {
$req = json_decode($rawBody); // NOTE: objects, not associative arrays
// Streaming is a flag on the same request, not a second endpoint.
$streaming = isset($req->stream) && $req->stream;
$messages = isset($req->messages) ? $req->messages : null;
if (!is_array($messages) || count($messages) === 0) {
echo json_encode(['error' => 'messages must be a non-empty array']);
return;
}
// The system prompt is sent separately and prepended here, matching the
// Node route, so the client does not have to rebuild it every turn.
$system = isset($req->system) ? $req->system : '';
$cacheable = false;
if ($system !== '') {
$sys = new stdClass();
$sys->role = 'system';
// Mark the instructions as cacheable. One Code Chat request makes
// several calls with the same instructions, so this cuts the cost.
$cacheable = strlen($system) > 2000
&& (!isset($req->cacheContext) || $req->cacheContext !== false);
if ($cacheable) {
$block = new stdClass();
$block->type = 'text';
$block->text = $system;
$block->cache_control = (object) ['type' => 'ephemeral'];
$sys->content = [$block];
} else {
$sys->content = $system;
}
array_unshift($messages, $sys);
}
// Cache what has been sent so far, so later calls in the same request do not
// pay for the whole conversation again.
if ($cacheable) $messages = cb_mark_conversation_cacheable($messages);
$data = [
'model' => isset($req->model) && $req->model ? $req->model : $defaultModel,
'messages' => $messages,
'temperature' => isset($req->temperature) ? floatval($req->temperature) : 0.7,
'top_p' => isset($req->topP) ? floatval($req->topP) : 0.9,
];
// Code Chat sends plain tool definitions; providers expect each wrapped.
if (!empty($req->tools) && is_array($req->tools)) {
$tools = [];
foreach ($req->tools as $t) {
$wrapped = new stdClass();
$wrapped->type = 'function';
$wrapped->function = $t;
$tools[] = $wrapped;
}
$data['tools'] = $tools;
$data['tool_choice'] = 'auto';
}
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
];
// Streams the reply back so the answer appears as it is written.
// Used automatically when the request has `stream: true`.
if ($streaming) {
$data['stream'] = true;
$data['stream_options'] = (object) ['include_usage' => true];
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // stop nginx buffering the stream
while (ob_get_level() > 0) { ob_end_flush(); }
/*
One attempt. A failed stream reports its status in the headers only, so
the body is collected rather than forwarded — it is needed both for the
error frame and to decide whether a retry is worth making.
*/
$attempt = function ($payload) use ($url, $headers, &$streamPending) {
$status = 200; $retryAfter = null; $errBody = '';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$status, &$retryAfter) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $header, $m)) $status = (int) $m[1];
if (stripos($header, 'retry-after:') === 0) {
$retryAfter = (int) trim(substr($header, strlen('retry-after:')));
}
return strlen($header);
});
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$status, &$errBody, &$streamPending) {
if ($status >= 400) { $errBody .= $chunk; return strlen($chunk); }
return cb_forward_sse_chunk($chunk, $streamPending);
});
curl_exec($ch);
curl_close($ch);
return ['status' => $status, 'retryAfter' => $retryAfter, 'body' => $errBody];
};
$streamPending = '';
$res = $attempt($data);
/*
Some newer OpenAI models apply a default reasoning effort their
chat/completions endpoint will not combine with function tools, and say
so. Older models reject the argument outright, so it cannot always be
sent — the provider's own message decides, once.
*/
if ($res['status'] >= 400 && stripos($res['body'], 'reasoning_effort') !== false
&& !isset($data['reasoning_effort'])) {
$data['reasoning_effort'] = 'none';
$streamPending = '';
$res = $attempt($data);
}
if ($res['status'] >= 400) {
$parsed = json_decode($res['body'], true);
$msg = $parsed['error']['message'] ?? ('Request failed with status code ' . $res['status']);
$frame = ['error' => ['message' => $msg], 'status' => $res['status']];
if ($res['retryAfter']) $frame['retryAfter'] = $res['retryAfter'];
echo 'data: ' . json_encode($frame) . "\n";
flush();
} elseif (strpos(ltrim($streamPending), 'data:') === 0) {
echo $streamPending . "\n";
flush();
}
echo "data: [DONE]\n\n";
flush();
return;
}
$send = function ($payload) use ($url, $headers) {
$retryAfter = null;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
// Capture Retry-After, so Code Chat waits the right amount of time
// when you are rate limited.
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$retryAfter) {
if (stripos($header, 'retry-after:') === 0) {
$retryAfter = (int) trim(substr($header, strlen('retry-after:')));
}
return strlen($header);
});
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = $body === false ? curl_error($ch) : null;
curl_close($ch);
return ['body' => $body, 'status' => $status, 'retryAfter' => $retryAfter, 'curlError' => $err];
};
$res = $send($data);
/*
Some newer OpenAI models apply a default reasoning effort their
chat/completions endpoint will not combine with function tools, and say so.
Older models reject the argument outright, so it cannot always be sent —
the provider's own message decides, once.
*/
if ($res['status'] >= 400 && is_string($res['body'])
&& stripos($res['body'], 'reasoning_effort') !== false
&& !isset($data['reasoning_effort'])) {
$data['reasoning_effort'] = 'none';
$res = $send($data);
}
$response = $res['body'];
$status = $res['status'];
$retryAfter = $res['retryAfter'];
if ($response === false) {
echo json_encode(['error' => 'cURL Error: ' . $res['curlError']]);
return;
}
$responseData = json_decode($response, true);
if ($status >= 400) {
$msg = isset($responseData['error']['message'])
? $responseData['error']['message']
: 'Request failed with status code ' . $status;
$out = ['error' => $msg, 'status' => $status];
if ($retryAfter) $out['retryAfter'] = $retryAfter;
echo json_encode($out);
return;
}
if (isset($responseData['error'])) {
$msg = is_array($responseData['error']) && isset($responseData['error']['message'])
? $responseData['error']['message']
: (is_string($responseData['error']) ? $responseData['error'] : 'provider error');
echo json_encode(['error' => $msg]);
return;
}
$message = isset($responseData['choices'][0]['message'])
? $responseData['choices'][0]['message']
: null;
if (!$message) {
echo json_encode(['error' => 'no message in provider response']);
return;
}
// Return the whole message: Code Chat needs `tool_calls` to continue.
echo json_encode([
'message' => $message,
'usage' => isset($responseData['usage']) ? $responseData['usage'] : null,
]);
}
?>
2. Using OpenAI — replace api/sendcommand.php with this
<?php
require_once 'config.php';
require_once 'conversation.php';
require_once 'common.php';
$DEFAULT_MODEL = 'gpt-4o-mini';
$RESPONSE_ENDPOINT_MODELS = ['gpt-5.1-codex-mini', 'gpt-5.1-codex'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody, true);
// Image generation — one call, answered from the same route so a host
// needs no extra endpoint. Neither v1 nor v2 sends `imageModel`.
if (!empty($body['imageModel'])) {
cb_serve_openai_image($body, $OPENAI_API_KEY);
exit;
}
// chatEngine 'v2' — conversational shape. No `messages` = v1, unchanged.
// v2 always uses chat/completions, never the v1/responses endpoint.
if (!empty($body['messages'])) {
cb_serve_conversation(
$rawBody,
'https://api.openai.com/v1/chat/completions',
$OPENAI_API_KEY,
$DEFAULT_MODEL
);
exit;
}
$question = $body['question'];
$context = $body['context'];
$system = $body['system'];
$functs = $body['functs'];
$model = $body['model'] ?? $DEFAULT_MODEL;
// Determine which endpoint to use
$useResponsesEndpoint = in_array($model, $RESPONSE_ENDPOINT_MODELS);
$url = $useResponsesEndpoint
? 'https://api.openai.com/v1/responses'
: 'https://api.openai.com/v1/chat/completions';
$messages = [
['role' => 'system', 'content' => $system],
['role' => 'assistant', 'content' => $context ?: ''],
['role' => 'user', 'content' => $question]
];
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $OPENAI_API_KEY,
];
// Build request body based on endpoint
if ($useResponsesEndpoint) {
$data = [
'model' => $model,
'input' => $messages,
];
} else {
$data = [
'model' => $model,
'messages' => $messages,
];
if (!empty($functs)) {
$data['functions'] = $functs;
}
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$response = curl_exec($ch);
if ($response === false) {
// Handle the cURL error here
echo json_encode(['error' => 'cURL Error: ' . curl_error($ch)]);
} else {
$responseData = json_decode($response, true);
if ($useResponsesEndpoint) {
// Normalize v1/responses format to match v1/chat/completions
$messageOutput = null;
foreach ($responseData['output'] as $item) {
if ($item['type'] === 'message') {
$messageOutput = $item;
break;
}
}
$answer = [
'choices' => [[
'message' => [
'role' => $messageOutput['role'],
'content' => $messageOutput['content'][0]['text']
]
]]
];
$usage = $responseData['usage'];
} else {
// Original v1/chat/completions logic
$answer = empty($functs)
? $responseData
: ($responseData['choices'][0]['message']['function_call'] ?
$responseData['choices'][0]['message']['function_call']['arguments'] :
$responseData['choices'][0]['message']);
$usage = $responseData['usage'];
}
echo json_encode(['answer' => $answer, 'usage' => $usage]);
curl_close($ch);
}
}
?>
/*
Image generation via OpenAI — one call, no queue.
The one difference from OpenRouter is that OpenAI splits the two operations:
/images/generations for a new image, /images/edits for one derived from a
reference. An edit takes multipart form data rather than JSON, so a reference
arriving as a data URL is decoded back to bytes here.
Either way the API answers with base64. It is saved here and only a URL goes
back to the browser — base64 in the page would be written into every save of
that content.
*/
function cb_serve_openai_image($body, $apiKey) {
global $path, $urlpath;
$references = $body['input_references'] ?? [];
if (!is_array($references)) $references = [];
$ch = curl_init();
if ($references !== []) {
$fields = [
'model' => $body['imageModel'],
'prompt' => $body['prompt'] ?? '',
];
if (!empty($body['imageSize'])) {
$fields['size'] = $body['imageSize']; // OpenAI takes pixels
}
$i = 0;
foreach ($references as $url) {
$bytes = strpos($url, 'data:') === 0
? base64_decode(substr($url, strpos($url, ',') + 1))
: @file_get_contents($url);
if ($bytes === false) continue;
$tmp = tempnam(sys_get_temp_dir(), 'cbimg');
file_put_contents($tmp, $bytes);
$fields['image[]'] = new CURLFile($tmp, 'image/png', 'reference-' . (++$i) . '.png');
}
curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/images/edits');
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $apiKey]);
} else {
$payload = [
'model' => $body['imageModel'],
'prompt' => $body['prompt'] ?? '',
];
if (!empty($body['imageSize'])) {
$payload['size'] = $body['imageSize']; // OpenAI takes pixels
}
curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/images/generations');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
}
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$err = curl_error($ch);
curl_close($ch);
echo json_encode(['error' => $err]);
return;
}
curl_close($ch);
$data = json_decode($response, true);
if ($status < 200 || $status >= 300) {
echo json_encode([
'error' => $data['error']['message'] ?? 'Request failed with status code ' . $status,
'status' => $status,
]);
return;
}
$b64 = $data['data'][0]['b64_json'] ?? '';
if ($b64 === '') {
echo json_encode(['error' => 'The generation returned no image.']);
return;
}
$folderPath = $body['customData']['folderPath'] ?? '';
$folderPathWithSlash = $folderPath ? $folderPath . '/' : '';
$filename = 'ai-' . generateRandomString(5) . '.png';
$filePath = $path . $folderPathWithSlash . $filename;
if (!is_dir(dirname($filePath))) {
mkdir(dirname($filePath), 0777, true);
}
if (file_put_contents($filePath, base64_decode($b64)) === false) {
echo json_encode(['error' => 'Could not save the generated image.']);
return;
}
echo json_encode(['url' => $urlpath . $folderPathWithSlash . $filename]);
}
3. Using OpenRouter — replace api/openrouter.php with this
<?php
require_once 'config.php';
require_once 'conversation.php';
require_once 'common.php';
set_time_limit(60);
$url = 'https://openrouter.ai/api/v1/chat/completions';
$model = 'openai/gpt-4o-mini';
$DEFAULT_TEMPERATURE = 0.6;
$DEFAULT_TOP_P = 0.9;
$DEFAULT_NUM = 1;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$rawBody = file_get_contents('php://input');
$body = json_decode($rawBody, true);
// Image generation — one call, answered from the same route so a host
// needs no extra endpoint. Neither v1 nor v2 sends `imageModel`.
if (!empty($body['imageModel'])) {
cb_serve_image_generation($body, $OPENROUTER_API_KEY);
exit;
}
// chatEngine 'v2' — conversational shape. No `messages` = v1, unchanged.
if (!empty($body['messages'])) {
cb_serve_conversation($rawBody, $url, $OPENROUTER_API_KEY, $model);
exit;
}
$question = $body['question'];
$context = $body['context'];
$system = $body['system'];
$functs = $body['functs'];
$temperature = $body['temperature'];
$topP = $body['topP'];
$num = $body['num'];
$messages = [
['role' => 'system', 'content' => $system],
['role' => 'assistant', 'content' => $context ?: ''],
['role' => 'user', 'content' => $question]
];
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $OPENROUTER_API_KEY,
];
$data = [
'messages' => $messages,
'temperature' => floatval($temperature) ?: $DEFAULT_TEMPERATURE,
'top_p' => floatval($topP) ?: $DEFAULT_TOP_P,
'n' => intval($num) ?: $DEFAULT_NUM,
];
if (!empty($functs)) {
// --- Function Calling Branch ---
// For function calling, only certain models are recommended.
// The model is used solely for request categorization. No impact on content generation.
// Models for function calling:
// - anthropic/claude-3.5-sonnet
// - qwen/qwen-2.5-72b-instruct
$data['model'] = 'anthropic/claude-3.5-sonnet';
$data['tools'] = [
[
'type' => 'function',
'function' => $functs[0]
]
];
} else {
// --- Content Generation Branch ---
$data['model'] = $model;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$response = curl_exec($ch);
if ($response === false) {
echo 'cURL Error: ' . curl_error($ch);
} else {
$responseData = json_decode($response, true);
if (empty($functs)) {
$answer = $responseData;
} else {
if (isset($responseData['choices']) && isset($responseData['choices'][0]['message'])) {
if (isset($responseData['choices'][0]['message']['tool_calls'][0]['function']['arguments'])) {
$answer = $responseData['choices'][0]['message']['tool_calls'][0]['function']['arguments'];
} else {
$answer = $responseData['choices'][0]['message'];
}
} else {
$answer = null;
}
}
echo json_encode(['answer' => $answer]);
curl_close($ch);
}
}
?>
/*
Image generation via OpenRouter — one call, no queue.
Unlike Fal there is no request id to poll and no result to fetch: the image
comes back in the same response. It arrives as base64 and is saved here, so the
browser only ever receives a URL — base64 in the page would be written into
every save of that content.
*/
function cb_serve_image_generation($body, $apiKey) {
global $path, $urlpath, $localSave, $s3, $bucket, $prefix;
$payload = [
'model' => $body['imageModel'],
'prompt' => $body['prompt'] ?? '',
];
if (!empty($body['imageSize'])) {
$payload['aspect_ratio'] = $body['imageSize']; // OpenRouter takes a ratio
}
if (!empty($body['input_references']) && is_array($body['input_references'])) {
$payload['input_references'] = array_map(function ($url) {
return ['type' => 'image_url', 'image_url' => ['url' => $url]];
}, $body['input_references']);
}
$ch = curl_init('https://openrouter.ai/api/v1/images');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 300,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
$err = curl_error($ch);
curl_close($ch);
echo json_encode(['error' => $err]);
return;
}
curl_close($ch);
$data = json_decode($response, true);
if ($status < 200 || $status >= 300) {
echo json_encode([
'error' => $data['error']['message'] ?? 'Request failed with status code ' . $status,
'status' => $status,
]);
return;
}
$b64 = $data['data'][0]['b64_json'] ?? '';
if ($b64 === '') {
echo json_encode(['error' => 'The generation returned no image.']);
return;
}
$folderPath = $body['customData']['folderPath'] ?? '';
$folderPathWithSlash = $folderPath ? $folderPath . '/' : '';
$filename = 'ai-' . generateRandomString(5) . '.jpg';
$filePath = $path . $folderPathWithSlash . $filename;
if (!is_dir(dirname($filePath))) {
mkdir(dirname($filePath), 0777, true);
}
if (file_put_contents($filePath, base64_decode($b64)) === false) {
echo json_encode(['error' => 'Could not save the generated image.']);
return;
}
echo json_encode(['url' => $urlpath . $folderPathWithSlash . $filename]);
}