*/
private static function caller_headers( string $key, string $feature ): array {
return array(
'Content-Type' => 'application/json',
'X-License-Key' => $key,
'X-CS-Plugin' => 'cyber-devtools',
'X-CS-Plugin-Version' => class_exists( 'CSDT_DevTools' ) ? CSDT_DevTools::VERSION : '0',
'X-CS-Feature' => $feature,
);
}
public static function register(): void {
// The full licence lifecycle (checkout, poll, save-key, OTP recover,
// sync, disconnect, boost, contact, refund) is served by the shared
// component via csdt_licence()->register_ajax(). The only handler
// left here is the plugin-specific managed-mode toggle.
CSDT_Ajax::register( 'wp_ajax_csdt_managed_ai_set_enabled', array( __CLASS__, 'ajax_set_enabled' ) );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
csdt_licence()->register_ajax();
}
// ── State ──────────────────────────────────────────────────────────────
/**
* Returns the subscription option merged with defaults.
*
* @since 1.10.265
* @return array
*/
public static function opts(): array {
$saved = get_option( self::OPTION, array() );
if ( ! is_array( $saved ) ) {
$saved = array(); }
return array_merge(
array(
'proxy_enabled' => 0,
'proxy_license_key' => '',
'proxy_status' => '', // '', pending, active, past_due, cancelled, refunded, inactive
'proxy_subscription_code' => '', // billing subscription code (PayPal I-…), needed to cancel
'proxy_usage' => 0,
'proxy_limit' => self::DEFAULT_LIMIT,
'proxy_boost' => 0, // rolled-over boost credits (never expire)
'proxy_reset_date' => '',
'proxy_session_id' => '',
'proxy_email' => '',
'proxy_subscribed_at' => '',
'proxy_status_ts' => 0,
'proxy_model' => '', // model the managed service last used (e.g. z-ai/glm-5.2)
),
$saved
);
}
/**
* Resolves the status the UI should act on, repairing stranded states.
*
* Incident 2026-07-22: a PayPal payment was approved but the broker
* rejected activation (plan-ID env mismatch, since fixed server-side).
* The failure handler blanked proxy_status while keeping the paid
* subscription in proxy_session_id, and the subscribe branch has no
* retry path, so the buyer was billed with a permanently dead panel.
*
* Two repairs, in priority order:
* 1. A stored `paypal_sub:` session with a non-active status renders as
* 'pending', whose auto-poll re-attempts broker activation.
* 2. A licence key with a blank OR 'inactive' status is re-synced from the broker
* (at most once per 10 minutes, only when $allow_remote). 'inactive' is included
* because every dispatch call site writes it as the `?? 'inactive'` default when a
* response carries no status field, found 2026-08-29 stranding a genuinely
* healthy free-tier licence (server said 'active', panel had shown 'inactive'
* since some earlier transient error, and nothing had ever re-checked it since,
* because 'inactive' matched neither repair's original condition).
*
* @since 1.10.360
* @param array $o Options array from opts(), updated in place on repair 2.
* @param bool $allow_remote Permit the (rate-limited) broker status call.
* @return string Effective status for rendering.
*/
private static function effective_status( array &$o, bool $allow_remote = false ): string {
$status = (string) $o['proxy_status'];
$key = (string) $o['proxy_license_key'];
$session = (string) $o['proxy_session_id'];
if ( 'active' !== $status && 'pending' !== $status && 0 === strpos( $session, 'paypal_sub:' ) ) {
// Try the broker activation server-side first, so the repair works
// even if the page's JS never runs. Idempotent: the broker returns
// the existing key for an already-activated subscription ID.
if ( $allow_remote && false === get_transient( 'csdt_managed_ai_paypal_retry' ) ) {
set_transient( 'csdt_managed_ai_paypal_retry', 1, 5 * MINUTE_IN_SECONDS );
$sub_id = substr( $session, strlen( 'paypal_sub:' ) );
$resp = wp_remote_post(
CSDT_Licence::BROKER . '/checkout',
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'timeout' => 20,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode(
array(
'email' => (string) $o['proxy_email'],
'site_url' => get_site_url(),
'type' => 'subscription',
'product' => 'cyber',
'payment_method' => 'paypal',
'payment_ref' => $sub_id,
)
),
)
);
$data = CSDT_Http::json( $resp )['data'];
if ( null !== $data ) {
if ( ! empty( $data['already_subscribed'] ) ) {
// The broker already holds an active licence for this
// email (issued on an earlier retry that never reached
// this site). It will not hand out the key without
// verification, clear the dead session and steer the
// user to the OTP recover flow instead of polling
// forever.
$o['proxy_session_id'] = '';
self::save( $o );
set_transient( 'csdt_managed_ai_recover_hint', 1, DAY_IN_SECONDS );
return (string) $o['proxy_status'];
}
if ( ! empty( $data['license_key'] ) ) {
$o['proxy_license_key'] = sanitize_text_field( (string) $data['license_key'] );
$o['proxy_status'] = 'active';
$o['proxy_enabled'] = 1;
$o['proxy_session_id'] = '';
$o['proxy_subscription_code'] = sanitize_text_field( $sub_id );
$o['proxy_subscribed_at'] = sanitize_text_field( (string) ( $data['created_at'] ?? $o['proxy_subscribed_at'] ) );
$o['proxy_status_ts'] = time();
self::save( $o );
return 'active';
}
}
}
return 'pending';
}
// 'inactive' is what record_refusal() stores for a genuine proxy refusal that names no
// status ("License not found"). Until 1.10.918 every dispatch wrote it on ANY 403,
// Cloudflare's HTML included. Unlike '', it used to have no path back to a remote
// re-check at all: neither branch above matches it, so a single bad response could
// strand an otherwise-healthy licence at 'inactive' forever, with render_panel()'s
// self-heal call running on every Home tab load and never touching it.
if ( ( '' === $status || 'inactive' === $status ) && '' !== $key && $allow_remote && false === get_transient( 'csdt_managed_ai_status_heal' ) ) {
set_transient( 'csdt_managed_ai_status_heal', 1, 10 * MINUTE_IN_SECONDS );
$resp = wp_remote_get(
CSDT_Licence::BROKER . '/status?key=' . rawurlencode( $key ) . '&product=cyber',
array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 8 )
);
$data = CSDT_Http::json( $resp )['data'];
$fresh = null !== $data ? (string) ( $data['status'] ?? '' ) : '';
if ( '' !== $fresh ) {
$o['proxy_status'] = sanitize_text_field( $fresh );
$o['proxy_status_ts'] = time();
self::save( $o );
$status = $o['proxy_status'];
}
}
return $status;
}
/**
* Persists the subscription option.
*
* @since 1.10.265
* @param array $opts Full option array to store.
* @return void
*/
private static function save( array $opts ): void {
update_option( self::OPTION, $opts );
}
/**
* Licence statuses the proxy reports, the only values record_refusal() will store.
*
* @since 1.10.918
*/
const PROXY_STATUSES = array( 'pending', 'active', 'past_due', 'cancelled', 'refunded', 'inactive' );
/**
* A proxy response's code and decoded body, or an exception when the body is not the proxy's.
*
* Every proxy endpoint answers JSON, refusals included. A body that is not JSON was written
* by something in between: Cloudflare's challenge or block page, a WAF, a captive portal,
* nginx's own 502. Until 1.10.918 each dispatch decoded to null and carried on, so a 200
* with an HTML body counted as success with empty text, and a Cloudflare 403 was saved as
* the licence going 'inactive'. Neither says anything about the licence or the answer.
*
* @since 1.10.918
* @param array|\WP_Error $resp What wp_remote_*() returned, already checked for WP_Error.
* @param string $what Prefix for the error, naming the call.
* @return array{0:int,1:array} HTTP code and decoded body.
* @throws \RuntimeException When the body is not JSON.
*/
private static function decode( $resp, string $what ): array {
$r = CSDT_Http::json( $resp );
if ( null === $r['data'] ) {
throw new \RuntimeException(
esc_html( "{$what} HTTP {$r['code']}: the response did not come from the CloudScale service (it was not JSON), most likely a network or Cloudflare block. Your licence status was not changed." )
);
}
return array( $r['code'], $r['data'] );
}
/**
* Whether a job poll was refused by the proxy itself, so no later poll can succeed.
*
* A 4xx carrying the proxy's own JSON error and no job status. 408 and 429 are excluded:
* both say "not now", not "never". A 5xx, a non-JSON body or no response at all is left to
* the batch deadline, because each of those can clear on the next poll.
*
* @since 1.10.934
* @param array $r A CSDT_Http::json() result.
* @return bool
*/
private static function poll_refused( array $r ): bool {
return $r['code'] >= 400 && $r['code'] < 500
&& ! in_array( $r['code'], array( 408, 429 ), true )
&& null !== $r['data'] && empty( $r['data']['status'] )
&& '' !== CSDT_Http::api_error( $r );
}
/**
* Stores the licence status a proxy 403 reports, and returns the message to show.
*
* Only a refusal the proxy wrote counts: JSON with an 'error' string. The status comes from
* its 'status' field when it names one, from "License " (the async queue's form)
* otherwise, and 'inactive' for "License not found" or any other genuine refusal, which
* effective_status() re-syncs from the broker within ten minutes if it was transient.
*
* @since 1.10.918
* @param array $o Options row, by reference: updated and saved when a status is recorded,
* so a caller that saves it again later keeps the status.
* @param array $data Decoded 403 body.
* @param string $what Prefix for the error, naming the call.
* @return string Message for the caller to throw or record.
*/
private static function record_refusal( array &$o, array $data, string $what ): string {
if ( ! isset( $data['error'] ) || ! is_string( $data['error'] ) ) {
return "{$what} HTTP 403: refused by something other than the CloudScale service. Your licence status was not changed.";
}
$status = isset( $data['status'] ) ? (string) $data['status'] : '';
if ( ! in_array( $status, self::PROXY_STATUSES, true )
&& preg_match( '/^License (\w+)$/', $data['error'], $m ) ) {
$status = $m[1];
}
if ( ! in_array( $status, self::PROXY_STATUSES, true ) ) {
$status = 'inactive';
}
$o['proxy_status'] = $status;
self::save( $o );
return 'Managed AI: ' . $data['error'];
}
/**
* True when AI calls should be routed through the managed proxy: the tier is
* toggled on AND a license key is stored. The proxy itself enforces the live
* subscription status (403 when inactive), so this gate stays lightweight.
*
* @since 1.10.265
* @return bool
*/
public static function should_use(): bool {
$o = self::opts();
return (int) $o['proxy_enabled'] === 1 && $o['proxy_license_key'] !== '';
}
/** True when the client currently holds an active subscription. */
public static function is_active(): bool {
return self::opts()['proxy_status'] === 'active';
}
/**
* Friendly label for the model the managed service last used (e.g. "GLM-5.2",
* "Claude Sonnet 5"). Empty until the first managed call records one.
*
* @return string
*/
public static function managed_model_label(): string {
$o = self::opts();
$id = (string) ( $o['proxy_model'] ?? '' );
// The live model + cost are refreshed ASYNCHRONOUSLY by the shared licence
// card's sync_status AJAX (see class-cloudscale-licence.php), which also
// runs on this page. We must NEVER fire a blocking /status request during
// page render, that added ~250ms to the admin page and tripped CS Monitor.
// Just read the last value the async refresh (or a scan) stored.
if ( '' === $id ) {
return '';
}
// A public tier name, never the vendor's id. Which engine serves a tier is a supply
// decision that changes, narration already moved to a voice model a fraction of the
// price, and printing the id turned that into a promise to every reader. The map lives
// in one place so the plugins, the pricing page and the admin console agree.
//
// The `?? $id` fallthrough that used to be here is what made this leak inevitable: any
// model not in the local two-entry list printed itself.
if ( class_exists( 'CSDT_Model_Names' ) ) {
return CSDT_Model_Names::public_name( $id );
}
return 'CloudScale managed model';
}
// ── AI dispatch ──────────────────────────────────────────────────────────
/**
* Forwards an Anthropic request to the managed proxy using the stored license
* key. Mirrors CSDT_AI_Dispatcher::call()'s contract so it is a drop-in for
* the direct Anthropic path.
*
* @since 1.10.265
* @param string $system System prompt.
* @param string $user_message Initial user message.
* @param string $model Resolved Anthropic model id.
* @param int $max_tokens Max tokens to generate.
* @param bool $accept_truncated Return the partial text of an answer cut off at its length
* limit instead of throwing (see CSDT_AI_Dispatcher::call()).
* @return string Response text.
* @throws \RuntimeException On network error, quota (429), inactive licence (403), an answer
* that stopped at its length limit, or other failure.
*/
public static function dispatch( string $system, string $user_message, string $model, int $max_tokens, bool $accept_truncated = false ): string {
$o = self::opts();
$key = (string) $o['proxy_license_key'];
$body = wp_json_encode(
array(
'model' => $model,
'max_tokens' => $max_tokens,
'system' => array(
array(
'type' => 'text',
'text' => $system,
'cache_control' => array( 'type' => 'ephemeral' ),
),
),
'messages' => array(
array(
'role' => 'user',
'content' => $user_message,
),
),
)
);
if ( false === $body ) {
throw new \RuntimeException( 'Failed to encode managed-AI request as JSON.' );
}
$resp = wp_remote_post(
self::PROXY . '/v1/messages',
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'timeout' => 180,
'headers' => self::caller_headers( $key, 'text' ),
'body' => $body,
)
);
if ( is_wp_error( $resp ) ) {
throw new \RuntimeException( esc_html( 'Managed AI network error: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ) );
}
list( $code, $data ) = self::decode( $resp, 'Managed AI' );
if ( 429 === $code ) {
throw new \RuntimeException( esc_html( 'Managed AI: ' . ( $data['error'] ?? 'Monthly request limit reached, top up with a boost or wait for the monthly reset.' ) ) );
}
if ( 403 === $code ) {
// License inactive/cancelled, cache the reported status so the UI updates.
throw new \RuntimeException( esc_html( self::record_refusal( $o, $data, 'Managed AI' ) ) );
}
if ( 200 !== $code ) {
$detail = $data['error']['message'] ?? ( $data['error'] ?? '' );
throw new \RuntimeException( esc_html( "Managed AI HTTP {$code}" . ( $detail ? ": {$detail}" : '' ) ) );
}
$text = CSDT_AI_Dispatcher::extract_claude_text( $data['content'] ?? array() );
// Record the model the managed service actually used, so the UI can show it
// truthfully, the proxy may serve a different model than the client requests.
if ( ! empty( $data['model'] ) ) {
$o['proxy_model'] = sanitize_text_field( (string) $data['model'] );
}
// Optimistic local increment, the server counter is authoritative; a 429
// still fires at the true limit regardless of this cached value.
$o['proxy_usage'] = (int) $o['proxy_usage'] + 1;
self::save( $o );
// A cut-off answer is a 200 as well. The proxy answers in Anthropic's shape whichever
// backend served it, so stop_reason is read the same way for every backend. Checked
// after the usage is saved: the completion was spent either way.
if ( ! $accept_truncated && CSDT_AI_Dispatcher::stopped_at_length( 'anthropic', $data ) ) {
throw new \RuntimeException( esc_html( CSDT_AI_Dispatcher::truncation_message( 'CloudScale managed AI, ' . ( ! empty( $data['model'] ) ? (string) $data['model'] : $model ) ) ) );
}
return $text;
}
/**
* Several completions at once, via the proxy's async queue.
*
* WHY THIS EXISTS
* ---------------
* dispatch() above is one blocking request, and the proxy caps /v1/messages at 90s
* because Cloudflare's edge gives up at 100s. The deep scan's two judgement halves ask
* for 14336 output tokens each and do not fit: on 2026-08-11 both answered 504 at
* 90.18s and 90.30s, and the plugin then treated the empty body as a FORMAT error and
* retried, spending a second 90s window restating JSON rules to a cut connection.
*
* /v1/messages-async takes the same body, returns a job id in milliseconds, and runs
* the completion out of band with no gateway above it. Two things follow, and the
* second is the bigger one: the 90s ceiling stops applying, and because the jobs are
* submitted before either is waited on, they run CONCURRENTLY. The halves used to run
* one after the other.
*
* Per-call isolation is preserved deliberately. One half failing, refused, over
* quota, timed out, must never stop the other from being collected, because half a
* security audit is worth having and the existing sequential path already guarantees
* that. So this returns a result per call and throws only when the queue itself is
* unusable, which is the signal the caller needs to fall back to the sync path.
*
* @since 1.10.560
* A job that finished at its length limit comes back ok=false with a
* CSDT_AI_Dispatcher::TRUNCATED_MARKER error, unless its entry carries
* 'accept_truncated' => true. The stop reason is read from the poll's own 'stop_reason'
* field when the proxy sends one, otherwise from the stored response body.
*
* @param array $calls Keyed by label.
* @return array Same keys as $calls.
* @throws \RuntimeException When the queue is unreachable or refuses every job, so the
* caller can fall back rather than report a scan failure.
*/
public static function dispatch_async( array $calls ): array {
if ( ! $calls ) {
return array();
}
$o = self::opts();
$key = (string) $o['proxy_license_key'];
// ── Submit everything first. This is the parallelism. ────────────────────────
$jobs = array();
$out = array();
$queued = 0;
foreach ( $calls as $label => $c ) {
$out[ $label ] = array(
'ok' => false,
'text' => '',
'error' => '',
);
$body = wp_json_encode(
array(
'model' => (string) $c['model'],
'max_tokens' => (int) $c['max_tokens'],
'system' => array(
array(
'type' => 'text',
'text' => (string) $c['system'],
'cache_control' => array( 'type' => 'ephemeral' ),
),
),
'messages' => array(
array(
'role' => 'user',
'content' => (string) $c['user'],
),
),
)
);
if ( false === $body ) {
$out[ $label ]['error'] = 'Failed to encode the request as JSON.';
continue;
}
$resp = wp_remote_post(
self::PROXY . '/v1/messages-async',
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
// Enqueueing waits on nothing but a database write.
'timeout' => 20,
'headers' => self::caller_headers( $key, 'text-async' ),
'body' => $body,
)
);
if ( is_wp_error( $resp ) ) {
$out[ $label ]['error'] = 'Managed AI network error: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() );
continue;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
// A 404 means this proxy predates the async queue. That is not a scan failure,
// it is the signal to use the synchronous path, so it is thrown, not recorded.
// Checked before decoding: an old proxy's 404 page need not be JSON.
if ( 404 === $code ) {
throw new \RuntimeException( 'Managed AI: the async queue is not available on this proxy.' );
}
try {
list( $code, $data ) = self::decode( $resp, 'Managed AI' );
} catch ( \RuntimeException $e ) {
$out[ $label ]['error'] = $e->getMessage();
continue;
}
if ( 403 === $code ) {
$out[ $label ]['error'] = self::record_refusal( $o, $data, 'Managed AI' );
continue;
}
if ( 202 !== $code || empty( $data['job_id'] ) ) {
$out[ $label ]['error'] = 'Managed AI HTTP ' . $code
. ( isset( $data['error'] ) ? ': ' . (string) $data['error'] : '' );
continue;
}
$jobs[ $label ] = (string) $data['job_id'];
++$queued;
}
if ( 0 === $queued ) {
// Nothing was accepted. Distinguishing "the queue is broken" from "both calls
// were individually rejected" is not possible here, and in both cases the sync
// path is the better next move, so let the caller decide.
throw new \RuntimeException( 'Managed AI: no completion could be queued.' );
}
// ── Then collect. ───────────────────────────────────────────────────────────
// POLL_MAX is the wall clock for the whole batch, not per job, because they run
// together. It sits above AI_JOB_TIMEOUT on the proxy (300s) so a job that the
// worker itself kills is read as 'failed' with a reason, rather than as this loop
// running out of patience, a timeout here would report the wrong cause.
$poll_every = 3;
$poll_max = 360;
$started = time();
while ( $jobs && ( time() - $started ) < $poll_max ) {
// Background scan path only, never a page load. No phpcs directive: no installed
// sniff reports sleep(), so the annotation this replaces suppressed nothing while
// reading as reviewed.
sleep( $poll_every );
foreach ( $jobs as $label => $job_id ) {
$resp = wp_remote_get(
self::PROXY . '/v1/messages-async?job_id=' . rawurlencode( $job_id ) . '&key=' . rawurlencode( $key ),
array( 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 20 )
);
$r = CSDT_Http::json( $resp );
$data = $r['data'];
if ( self::poll_refused( $r ) ) {
// The proxy itself said no ("Job not found" once a job has expired or its
// row is gone). Asking again gets the same answer, so this ends the job now
// with the proxy's words. Until 1.10.934 it was treated like any other
// unreadable poll and reported six minutes later as "still running".
$out[ $label ]['error'] = 'the CloudScale service refused the job poll, ' . CSDT_Http::describe( $r );
unset( $jobs[ $label ] );
continue;
}
if ( null === $data || empty( $data['status'] ) ) {
// No response, or one from something in front of the proxy: a single failed
// poll is not a failed job. Keep waiting; the batch deadline ends this.
continue;
}
if ( 'queued' === $data['status'] || 'running' === $data['status'] ) {
continue;
}
if ( 'done' === $data['status'] ) {
$payload = is_array( $data['response'] ?? null ) ? $data['response'] : array();
$out[ $label ]['text'] = CSDT_AI_Dispatcher::extract_claude_text( $payload['content'] ?? array() );
$out[ $label ]['ok'] = ( '' !== $out[ $label ]['text'] );
// The poll's own stop_reason first (newer proxies send it beside the job
// status), then the stored body's, which every proxy has always kept.
$stop = array( 'stop_reason' => ! empty( $data['stop_reason'] ) ? (string) $data['stop_reason'] : (string) ( $payload['stop_reason'] ?? '' ) );
if ( CSDT_AI_Dispatcher::stopped_at_length( 'anthropic', $stop ) && empty( $calls[ $label ]['accept_truncated'] ) ) {
$out[ $label ]['ok'] = false;
$out[ $label ]['text'] = '';
$out[ $label ]['error'] = CSDT_AI_Dispatcher::truncation_message( 'CloudScale managed AI, ' . ( ! empty( $payload['model'] ) ? (string) $payload['model'] : (string) $calls[ $label ]['model'] ) );
} elseif ( ! $out[ $label ]['ok'] ) {
$out[ $label ]['error'] = 'the completion returned no text';
}
if ( ! empty( $payload['model'] ) ) {
$o['proxy_model'] = sanitize_text_field( (string) $payload['model'] );
}
// Counted per completed job, matching dispatch()'s optimistic increment.
// The server counter stays authoritative either way.
$o['proxy_usage'] = (int) $o['proxy_usage'] + 1;
} else {
$out[ $label ]['error'] = (string) ( $data['error'] ?? 'the completion failed' );
}
unset( $jobs[ $label ] );
}
}
// Anything still outstanding ran past the batch deadline. Named as such rather than
// left blank, because "no reply" and "we stopped waiting" have different fixes.
foreach ( array_keys( $jobs ) as $label ) {
$out[ $label ]['error'] = 'the completion was still running after ' . $poll_max . 's';
}
self::save( $o );
return $out;
}
/**
* Managed image generation. Routes gpt-image-2 through the CloudScale proxy
* (server-side OpenAI key + separate monthly image allowance) so managed
* customers need no OpenAI key of their own. Mirrors dispatch(); returns a
* local temp PNG path, matching CSDT_AI_Dispatcher::generate_image()'s contract.
*
* @throws \RuntimeException on quota (429), inactive licence (403), or other error.
*/
public static function dispatch_image( string $prompt, string $size = '1536x1024', string $quality = 'standard' ): string {
$o = self::opts();
$key = (string) $o['proxy_license_key'];
$body = wp_json_encode(
array(
'prompt' => $prompt,
'size' => $size,
'quality' => $quality,
)
);
if ( false === $body ) {
throw new \RuntimeException( 'Failed to encode managed image request as JSON.' );
}
$resp = wp_remote_post(
self::PROXY . '/v1/images',
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'timeout' => 200,
'headers' => self::caller_headers( $key, 'image' ),
'body' => $body,
)
);
if ( is_wp_error( $resp ) ) {
throw new \RuntimeException( esc_html( 'Managed AI image network error: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ) );
}
list( $code, $data ) = self::decode( $resp, 'Managed AI image' );
if ( 429 === $code ) {
throw new \RuntimeException( esc_html( 'Managed AI: ' . ( $data['error'] ?? 'Monthly image limit reached, buy an image boost or wait for the monthly reset.' ) ) );
}
if ( 403 === $code ) {
throw new \RuntimeException( esc_html( self::record_refusal( $o, $data, 'Managed AI image' ) ) );
}
if ( 200 !== $code ) {
$detail = $data['error']['message'] ?? ( $data['error'] ?? '' );
throw new \RuntimeException( esc_html( "Managed AI image HTTP {$code}" . ( $detail ? ": {$detail}" : '' ) ) );
}
$b64 = $data['data'][0]['b64_json'] ?? '';
if ( ! $b64 ) {
throw new \RuntimeException( 'Managed image generation returned no image data.' );
}
$tmp = wp_tempnam( 'csdt-ai-img.png' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- one write of the decoded image to the wp_tempnam() file just created, which the caller imports and deletes
if ( false === file_put_contents( $tmp, base64_decode( $b64 ) ) ) {
throw new \RuntimeException( 'Failed to write managed image to temp file.' );
}
// Re-read before writing: this row was fetched before a call that can take three
// minutes, and a status refresh in that window would be overwritten by the stale
// copy held here.
$o = self::opts();
// Optimistic local increment; the server counter is authoritative.
$o['proxy_usage'] = (int) $o['proxy_usage'] + 1;
/*
* The IMAGE allowance, as the server reports it on this same response.
*
* Stored, not just returned, because the figures are read by a different request: the
* generation runs in a detached worker that ends in wp_die(), and the browser learns what
* happened from a later poll. Whatever the worker does not persist, the poll cannot say.
*
* Written into the very keys the licence card's "Image generations" meter reads
* (proxy_img_*), so the progress overlay and the card can never quote different numbers
* at the same person, and the card stops being stale until the next status sync.
*/
if ( isset( $data['img_limit'] ) ) {
$o['proxy_img_usage'] = (int) $data['img_used'];
$o['proxy_img_limit'] = (int) $data['img_limit'];
$o['proxy_img_boost'] = (int) $data['img_boost'];
}
self::save( $o );
return $tmp;
}
/**
* Managed OpenAI chat call (the image prompt-writer). Routes through the proxy's
* OpenAI passthrough so managed customers use the SAME OpenAI models as the BYO
* image generator, cost-metered into the unified monthly cap. Mirrors dispatch().
*
* @throws \RuntimeException on quota (429), inactive licence (403), or other error.
*/
public static function dispatch_openai_text( string $system, string $user_message, int $max_tokens, string $model ): string {
$o = self::opts();
$key = (string) $o['proxy_license_key'];
$body = wp_json_encode(
array(
'model' => $model,
'max_tokens' => $max_tokens,
'system' => $system,
'user' => $user_message,
)
);
if ( false === $body ) {
throw new \RuntimeException( 'Failed to encode managed OpenAI request as JSON.' );
}
$resp = wp_remote_post(
self::PROXY . '/v1/openai-chat',
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'timeout' => 90,
'headers' => self::caller_headers( $key, 'openai-text' ),
'body' => $body,
)
);
if ( is_wp_error( $resp ) ) {
throw new \RuntimeException( esc_html( 'Managed AI OpenAI network error: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ) );
}
list( $code, $data ) = self::decode( $resp, 'Managed AI OpenAI' );
if ( 429 === $code ) {
throw new \RuntimeException( esc_html( 'Managed AI: ' . ( $data['error'] ?? 'Monthly cost limit reached, wait for the monthly reset.' ) ) );
}
if ( 403 === $code ) {
throw new \RuntimeException( esc_html( self::record_refusal( $o, $data, 'Managed AI OpenAI' ) ) );
}
if ( 200 !== $code ) {
$detail = $data['error']['message'] ?? ( $data['error'] ?? '' );
throw new \RuntimeException( esc_html( "Managed AI OpenAI HTTP {$code}" . ( $detail ? ": {$detail}" : '' ) ) );
}
// A cut-off prompt is a 200 too. Drawn as written, it is a confident picture of the wrong
// thing: on 2026-10-17 a 119-character fragment about a limestone pavilion, with no
// subject, became the featured image of a post about Cloudflare. The proxy now refuses
// these itself; this is for an older proxy, or one whose check is ever lost.
if ( 'length' === ( $data['choices'][0]['finish_reason'] ?? '' ) ) {
throw new \RuntimeException( 'Managed AI: the image prompt writer ran out of room and returned an unfinished prompt, so no image was generated. Try again.' );
}
return trim( $data['choices'][0]['message']['content'] ?? '' );
}
// ── Ajax: subscribe / billing ──────────────────────────────────────────
/**
* Shared capability gate for every ajax handler. The nonce is verified
* directly with check_ajax_referer() at the top of each handler (so PHPCS /
* Plugin Check can trace it in-scope); this only enforces manage_options.
*/
private static function guard(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Forbidden' ), 403 );
}
}
/**
* Toggles whether the managed proxy is used for AI calls.
*
* @since 1.10.265
* @return void
*/
public static function ajax_set_enabled(): void {
check_ajax_referer( self::NONCE, 'nonce' );
self::guard();
try {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified via check_ajax_referer() at handler top
$enabled = (int) (bool) sanitize_key( wp_unslash( $_POST['enabled'] ?? 0 ) );
$o = self::opts();
$o['proxy_enabled'] = $enabled;
self::save( $o );
wp_send_json_success();
} catch ( \Throwable $e ) {
CSDT_Ajax::fail( $e, __METHOD__ );
}
}
// ── Assets ───────────────────────────────────────────────────────────────
/**
* Enqueues the managed-AI front-end script on the plugin's tools page only.
*
* @since 1.10.265
* @param string $hook Current admin page hook suffix.
* @return void
*/
public static function enqueue_assets( $hook ): void {
if ( ! in_array( $hook, array( 'toplevel_page_' . CSDT_DevTools::TOOLS_SLUG, 'tools_page_' . CSDT_DevTools::TOOLS_SLUG, 'admin_page_' . CSDT_DevTools::TOOLS_SLUG ), true ) ) {
return;
}
wp_enqueue_script(
'csdt-licence',
plugins_url( 'assets/cloudscale-licence.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'jquery' ),
CSDT_DevTools::VERSION,
true
);
wp_enqueue_script(
'csdt-managed-ai',
plugins_url( 'assets/cs-managed-ai.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'csdt-licence' ),
CSDT_DevTools::VERSION,
true
);
wp_localize_script(
'csdt-managed-ai',
'csdtManagedAi',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE ),
)
);
}
// ── Rendering ──────────────────────────────────────────────────────────
/**
* Renders the compact Home-dashboard status card (one grid cell). Matches the
* inline-styled card pattern used by the other Home cards.
*
* @since 1.10.265
* @return void
*/
public static function render_home_card(): void {
$o = self::opts();
$anchor = '#cs-managed-ai-panel';
// This tile has its own palette and its own short call to action, but it
// must not be able to disagree with the licence card about WHICH state the
// licence is in, it used to switch on the raw status, so a stored 'active'
// with no key showed a green "Active" tile next to a card reading "License:
// Not Found". CSDT_Licence::status_key() is the one arbiter.
$status = CSDT_Licence::status_key(
self::effective_status( $o ),
'' !== (string) $o['proxy_license_key'],
'' !== (string) $o['proxy_session_id']
);
// One state per licence status; the Home stylesheet draws each (.cs-stat-card.is-*).
if ( 'active' === $status ) {
$state = 'ok';
$label = __( 'Active', 'cloudscale-cyber-devtools' );
$credits = CSDT_Licence::credits_left(
array(
'limit' => (int) $o['proxy_limit'],
'usage' => (int) $o['proxy_usage'],
'boost' => (int) $o['proxy_boost'],
)
);
// _n() rather than __(), for two reasons. The sniff wants the
// translators comment on the line directly above the gettext call, and
// it was two lines up with the sprintf( between, close enough to read
// as correct, not close enough to count. And the string it guarded said
// "1 credits left" on the last credit, which is the number a user is
// most likely to be looking at.
$detail = sprintf(
/* translators: %d: number of AI requests remaining (monthly allowance + boost credits). */
_n( '%d credit left', '%d credits left', $credits, 'cloudscale-cyber-devtools' ),
$credits
);
$cta = __( 'Manage', 'cloudscale-cyber-devtools' );
} elseif ( 'invalid' === $status ) {
$state = 'bad';
$label = __( 'License not found', 'cloudscale-cyber-devtools' );
$detail = __( 'Reconnect your licence or resubscribe.', 'cloudscale-cyber-devtools' );
$cta = __( 'Fix', 'cloudscale-cyber-devtools' );
} elseif ( 'pending' === $status ) {
$state = 'info';
$label = __( 'Activating…', 'cloudscale-cyber-devtools' );
$detail = __( 'Waiting for payment to confirm.', 'cloudscale-cyber-devtools' );
$cta = __( 'View', 'cloudscale-cyber-devtools' );
} elseif ( 'past_due' === $status ) {
$state = 'warn';
$label = __( 'Payment failed', 'cloudscale-cyber-devtools' );
$detail = __( 'Update your card to restore AI access.', 'cloudscale-cyber-devtools' );
$cta = __( 'Fix', 'cloudscale-cyber-devtools' );
} elseif ( 'cancelled' === $status ) {
$state = 'neutral';
$label = __( 'Cancelled', 'cloudscale-cyber-devtools' );
$detail = __( 'Active until the end of the billing period.', 'cloudscale-cyber-devtools' );
$cta = __( 'Resubscribe', 'cloudscale-cyber-devtools' );
} else {
// Not subscribed, the conversion card.
$state = 'accent';
$label = __( 'Pay monthly for AI', 'cloudscale-cyber-devtools' );
$detail = __( 'No API key? $10/mo via PayPal, cancel anytime.', 'cloudscale-cyber-devtools' );
$cta = __( 'Subscribe', 'cloudscale-cyber-devtools' );
}
?>
✅ '
. esc_html__( 'Your payment is confirmed', 'cloudscale-cyber-devtools' ) . ', '
. esc_html__( 'a licence already exists for your email on the licence server; this site just needs to reconnect to it. Click "Recover Licence" below and enter the 6-digit code we email you. Do NOT pay again.', 'cloudscale-cyber-devtools' )
. '
' . $intro;
}
?>
render_card(
array(
'card_id' => 'cs-managed-ai',
'icon' => '💳',
'heading' => 'Managed AI, pay monthly, no API key needed',
'card_bg' => '#fff',
'card_border' => '#c7d2fe',
// The value proposition, not just the terms: what the subscription buys and why the
// allowance stretches. See pricing.php for the same argument at length.
'price_text' => 'One allowance covers text, images and narration, bought in bulk and routed to the cheapest capable tier, so it goes further than the same spend on premium rates. No AI account needed. Cancel anytime · 7-day money-back guarantee',
'subscribe_intro_html' => $intro,
'open_recover' => $recover_hint,
'active_extra' => static function () use ( $enabled ) {
?>