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]);
}

View this page on GitHub

© 2026 InnovaStudio

This site is built with Site Builder Kit