Skip to content

Conversation

@arifulhoque7
Copy link
Contributor

@arifulhoque7 arifulhoque7 commented Dec 1, 2025

Summary

This PR enhances the AI settings page with several user-requested features to improve the configuration experience and ensure API keys are valid before use.

Changes Made

1. Temperature Field (Dynamic Control)

  • Added temperature input field with HTML5 number validation (range: 0.0-1.0, step: 0.1)
  • Temperature value is saved to wpuf_ai[temperature] and used in all AI API calls
  • FormGenerator loads temperature from settings as default when generating forms
  • Real-time client-side validation with visual feedback (red border for invalid, green for valid)

Files Modified:

  • includes/functions/settings-options.php (lines 666-673, 1246-1267)
  • includes/AI/FormGenerator.php (lines 113-116)

2. Masked API Key Display

  • API keys now show only first 4 and last 4 characters (e.g., AIza**********************xyz)
  • Added "Change" button to allow editing existing keys
  • Masked display switches to password input when editing
  • Full security maintained - actual keys remain in password fields

Files Modified:

  • includes/functions/settings-options.php (lines 760-788, 867-900)

3. Test Connection Button

  • Added "Test Connection" button with spinner animation
  • Tests the currently selected provider without requiring save
  • Makes minimal API call (5 tokens) to verify API key validity
  • Shows provider-specific success/error messages
  • Auto-enables model field and refreshes models on success
  • Success messages auto-hide after 5 seconds, errors stay visible

Implementation:

  • Provider-specific test methods for OpenAI, Anthropic, and Google
  • Accepts API key, provider, and model from UI (not just saved settings)
  • Proper timeout clearing to ensure messages always display
  • Dynamic provider selection - tests whichever provider is currently selected

Files Modified:

  • includes/AI/FormGenerator.php (lines 863-1074) - Added test_connection() and provider-specific test methods
  • includes/AI/RestController.php (lines 125-146, 405-423) - Added REST endpoint with parameters
  • includes/functions/settings-options.php (lines 787-806, 984-1066) - Frontend AJAX integration

4. Reorganized Field Order

New logical workflow order:

  1. AI Provider selection
  2. API Key entry (with test button and masked display)
  3. AI Model selection (enabled after valid API key)
  4. Temperature setting

Files Modified:

  • includes/functions/settings-options.php (lines 640-674)

Technical Details

Temperature Handling:

  • Stored in wpuf_ai['temperature'] option
  • Default: 0.7 (balanced creativity)
  • Validated and clamped: max(0.0, min(1.0, $value))
  • Passed to API calls via $options['temperature']

Test Connection Flow:

  1. JavaScript reads selected provider, API key, and model from UI
  2. Sends POST to /wpuf/v1/ai-form-builder/test with JSON payload
  3. Backend temporarily overrides provider/model/key for testing
  4. Makes minimal API request (5 tokens)
  5. Returns success/failure with specific error messages
  6. Frontend shows result with auto-hide for success

Security:

  • All inputs sanitized with sanitize_text_field()
  • API keys never exposed in responses
  • Nonce verification on all AJAX requests
  • Capability checks: check_permission() for test endpoint

Test Plan

  • Temperature field displays current value
  • Temperature validation prevents values outside 0.0-1.0
  • Temperature saves and applies to AI API calls
  • API key shows masked format when exists
  • "Change" button reveals password input
  • Test connection works for OpenAI
  • Test connection works for Anthropic
  • Test connection works for Google
  • Test uses currently selected provider (not saved)
  • Success/error messages display on every test
  • Model dropdown enables after successful test
  • Models refresh after successful connection
  • All existing functionality preserved

Breaking Changes

None. All changes are backward compatible.


Files Changed

  • includes/functions/settings-options.php (210 lines modified)
  • includes/AI/FormGenerator.php (27 lines modified)
  • includes/AI/RestController.php (31 lines modified)

Closes 1740

Summary by CodeRabbit

  • New Features
    • Added API connection testing with optional provider, model, and API key overrides.
    • UI for API key management: masked display, Change button, Test Connection flow, status feedback and spinner.
    • Temperature setting added to AI configuration (numeric input, 0–1, default 0.7) to control randomness.
    • Live model refresh and UI gating based on valid API key and selected provider.

✏️ Tip: You can customize this high-level summary in your review settings.

@arifulhoque7 arifulhoque7 requested a review from sapayth December 1, 2025 06:08
@arifulhoque7 arifulhoque7 self-assigned this Dec 1, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 1, 2025

Walkthrough

Refactors AI provider connection testing to accept optional API key/provider/model overrides, adds provider-specific test helpers (OpenAI, Anthropic, Google), changes the REST test route to accept those args and use POST semantics, and replaces the settings API key input with a masked interactive widget plus a temperature field.

Changes

Cohort / File(s) Summary
Provider API testing
includes/AI/FormGenerator.php
test_connection() signature updated to ($api_key = '', $provider = '', $model = ''). Added test_provider_api() orchestration and provider-specific helpers: test_openai_connection(), test_anthropic_connection(), test_google_connection(). Standardized error handling, per-request temperature defaulting, and state restoration after tests.
REST API configuration
includes/AI/RestController.php
Test endpoint changed from READABLE to CREATABLE. Added request args: optional api_key, provider, model with sanitization/validation. Handler forwards these params to FormGenerator::test_connection().
Settings UI and rendering
includes/functions/settings-options.php
Replaced plain API key input with wpuf_ai_api_key_field() interactive widget (masked key display, Change/Test Connection flows, REST test/model refresh integration). Added wpuf_ai_temperature_field() and a temperature setting (default 0.7) with numeric input and client-side validation/UX.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Review provider-specific test implementations for correct minimal API call shapes and error normalization.
  • Verify state restoration of provider/model/api_key in all success/error paths.
  • Validate REST schema validation/sanitization and client-side Test Connection interactions.

Possibly related PRs

Suggested labels

needs: dev review

Suggested reviewers

  • sapayth

Poem

🐰 I hopped in code to test and play,

Keys masked by day, revealed by way,
OpenAI, Claude, and Gemini too,
A gentle ping to check what's true,
Warm temps set to 0.7 — cheers anew! 🎉

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main enhancements introduced in this changeset: temperature control, API key masking, and connection testing.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arifulhoque7 arifulhoque7 added needs: dev review This PR needs review by a developer needs: testing labels Dec 1, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
includes/AI/FormGenerator.php (1)

873-922: Test connection with overrides properly manages state.

The implementation correctly:

  1. Reloads settings to get latest data
  2. Stores original values before overriding
  3. Validates API key presence
  4. Restores original values after testing
  5. Handles exceptions with proper error response

However, the catch block doesn't restore original values on exception.

The exception handler should restore original values to maintain consistency:

         } catch (\Exception $e) {
+            // Restore original values on exception
+            $this->current_provider = $original_provider;
+            $this->current_model = $original_model;
+            $this->api_key = $original_key;
+
             return [
                 'success' => false,
                 'provider' => $this->current_provider,
                 'message' => $e->getMessage()
             ];
         }
🧹 Nitpick comments (1)
includes/functions/settings-options.php (1)

811-814: Hidden inputs expose all provider API keys in page source.

All three provider API keys are stored in hidden input fields in the HTML source. While they're not directly visible, they can be viewed via browser dev tools or "View Source". This is acceptable for an admin-only settings page but worth noting.

Consider fetching non-current provider keys via AJAX only when the provider is switched, rather than embedding all keys in the DOM. This reduces the attack surface if an admin's browser session is compromised.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d91cfac and b31fe99.

📒 Files selected for processing (3)
  • includes/AI/FormGenerator.php (3 hunks)
  • includes/AI/RestController.php (2 hunks)
  • includes/functions/settings-options.php (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
includes/AI/RestController.php (1)
includes/AI/FormGenerator.php (1)
  • test_connection (873-923)
includes/AI/FormGenerator.php (2)
Lib/WeDevs_Settings_API.php (1)
  • get_option (616-625)
includes/AI/RestController.php (1)
  • test_connection (405-423)
includes/functions/settings-options.php (1)
Lib/WeDevs_Settings_API.php (1)
  • get_option (616-625)
🪛 PHPMD (2.15.0)
includes/functions/settings-options.php

1265-1265: Avoid unused parameters such as '$args'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (12)
includes/functions/settings-options.php (6)

650-656: LGTM - Field definition for API key with callback.

The field configuration correctly uses the callback type to render a custom API key UI component with masking and test connection functionality.


667-673: LGTM - Temperature field configuration.

The temperature field is properly defined with callback type and sensible default of 0.7.


757-769: API key masking logic is sound.

The masking implementation correctly shows first 4 and last 4 characters for keys longer than 8 characters, and fully masks shorter keys. This provides good UX while maintaining security.


1014-1026: AJAX test connection properly uses REST API with nonce.

The implementation correctly:

  • Uses POST method for the test endpoint
  • Sends JSON content type
  • Includes WP REST nonce via X-WP-Nonce header
  • Passes provider, API key, and model for testing

1265-1285: Temperature field implementation is correct; unused parameter is acceptable.

The static analysis flags $args as unused, but this is a required signature for WordPress Settings API callbacks. The implementation properly clamps values between 0.0 and 1.0 and uses HTML5 validation attributes.


790-798: Verify that proper capability checks are in place before this settings page is rendered.

While esc_attr() is properly used on the API key value, confirming admin-only access with capability checks (such as current_user_can() with appropriate capabilities) is essential to validate the claim that the XSS risk is minimal in this context.

includes/AI/RestController.php (2)

125-146: Good: POST method for test endpoint with optional parameters.

Changing from READABLE to CREATABLE (GET to POST) is the correct approach since:

  1. The request includes sensitive API keys that shouldn't be in URL/logs
  2. The operation has side effects (makes external API calls)
  3. Parameters are properly typed and sanitized

The sanitize_callback for api_key uses sanitize_text_field which is appropriate for API key strings.


405-423: LGTM - Test connection handler correctly passes parameters.

The handler properly extracts parameters from the request and forwards them to FormGenerator::test_connection(). The try-catch ensures errors are handled gracefully.

includes/AI/FormGenerator.php (4)

98-116: Good: Temperature defaults properly loaded from settings.

The implementation correctly:

  1. Loads settings with get_option('wpuf_ai', [])
  2. Only sets temperature if not already provided in options
  3. Uses floatval() for type safety with fallback to 0.7

932-950: LGTM - Provider dispatcher with clean switch statement.

The test_provider_api() method cleanly routes to provider-specific test methods and handles unknown providers gracefully.


955-995: OpenAI test connection is well-implemented.

The method uses a minimal request (max_tokens: 5, simple "Hi" message) to minimize API costs while still validating credentials. Error handling properly extracts the error message from the response body.


1000-1041: Anthropic test connection follows same pattern - good consistency.

The implementation mirrors OpenAI's approach with appropriate Anthropic-specific headers (x-api-key, anthropic-version).

@Rubaiyat-E-Mohammad
Copy link
Contributor

Token limit configuration is missing @arifulhoque7 vai
image

@arifulhoque7
Copy link
Contributor Author

Token limit configuration is missing @arifulhoque7 vai image

Token limit configuration can cause broken JSON in the form builder, thus we can't implement it here @Rubaiyat-E-Mohammad vai

@Rubaiyat-E-Mohammad Rubaiyat-E-Mohammad added QA Approved This PR is approved by the QA team and removed bug labels Dec 2, 2025
Copy link
Member

@sapayth sapayth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • use sprintf() to make translatable string with placeholder like urls, html attributes etc.
  • follow our projects coding standard for spacing, indentation etc.

[
'name' => 'api_key_current',
'label' => __( 'API Key', 'wp-user-frontend' ),
'desc' => __( 'Enter your AI service API key. Need help finding your <a href="https://platform.openai.com/api-keys" target="_blank" class="wpuf-api-key-link" data-openai="https://platform.openai.com/api-keys" data-anthropic="https://console.anthropic.com/settings/keys" data-google="https://aistudio.google.com/app/apikey" style="text-decoration: underline;">API Key?</a>', 'wp-user-frontend' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use sprintf() to make translatable string with placeholder like urls, html attributes etc.

@sapayth sapayth added needs: author reply This PR needs author feedback or code changes and removed needs: dev review This PR needs review by a developer labels Dec 3, 2025
Refactored AI provider connection test methods for better code consistency and error handling. Updated the API key settings field to use sprintf for localized help links, improving translation and maintainability.
@arifulhoque7
Copy link
Contributor Author

  • use sprintf() to make translatable string with placeholder like urls, html attributes etc.
  • follow our projects coding standard for spacing, indentation etc.

done @sapayth vai

@arifulhoque7 arifulhoque7 added needs: dev review This PR needs review by a developer and removed needs: author reply This PR needs author feedback or code changes labels Dec 4, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
includes/AI/FormGenerator.php (1)

567-599: Avoid sending Google API key in query string for main generation call

generate_with_google() still appends the API key as a ?key= query parameter on $endpoint, while test_google_connection() correctly uses the x-goog-api-key header. For server‑side code, using the header for both is preferable to avoid exposing keys in logs and intermediaries.

Refactor generate_with_google() to remove '?key=' . $this->api_key from the URL and instead include x-goog-api-key in the headers array, mirroring test_google_connection().

🧹 Nitpick comments (3)
includes/AI/FormGenerator.php (2)

873-923: Improve robustness of state restoration in test_connection

The overall flow (reload settings, allow overrides, temporarily swap API key, then delegate to test_provider_api()) looks good. However, if an exception is ever thrown after $this->current_provider / $this->current_model / $this->api_key are overridden, the catch block returns an error but never restores the originals, unlike generate_form().

Wrapping restoration in a finally-style pattern (or duplicating restoration in the catch) would make this more future‑proof and consistent with generate_form()’s restoration logic.


955-995: Connection test helpers are logically correct; adjust array formatting to satisfy PHPCS

  • The three test helpers correctly:
    • Use provider‑appropriate endpoints and auth headers.
    • Send a minimal 5‑token request.
    • Normalize responses into a { success, provider, message } structure.
  • Static hint at ~964: in test_openai_connection(), the multi‑item associative array that includes 'messages' should be expanded so each value starts on a new line to match the project’s coding standard (e.g., break [ [ 'role' => 'user', 'content' => 'Hi' ] ], across lines).

This is cosmetic but will clear the PHPCS warning.

Also applies to: 1000-1041, 1046-1091

includes/functions/settings-options.php (1)

976-991: Temperature field implementation is sound; keep $args and server‑side clamping

  • The temperature input is correctly configured as an HTML5 <input type="number"> with min="0", max="1", and step="0.1", and the JS adds nice immediate visual feedback.
  • wpuf_ai_temperature_field() clamps the value to [0.0, 1.0] before output, which is good defense in depth and aligns with the backend usage in FormGenerator::generate_form().

The $args parameter is intentionally unused but required by the Settings API callback signature. If PHPMD’s warning becomes noisy, a minimal fix is to rename it to $_args or add a short comment (// Required by Settings API callback signature) to make the intent clear.

Also applies to: 1274-1294

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b31fe99 and 0cfd6a5.

📒 Files selected for processing (2)
  • includes/AI/FormGenerator.php (2 hunks)
  • includes/functions/settings-options.php (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
includes/AI/FormGenerator.php (2)
Lib/WeDevs_Settings_API.php (1)
  • get_option (616-625)
includes/AI/RestController.php (1)
  • test_connection (405-423)
includes/functions/settings-options.php (1)
Lib/WeDevs_Settings_API.php (1)
  • get_option (616-625)
🪛 GitHub Check: Run PHPCS inspection
includes/AI/FormGenerator.php

[failure] 99-99:
Expected 1 spaces before closing parenthesis; 0 found


[failure] 99-99:
Expected 1 spaces after opening parenthesis; 0 found


[warning] 964-964:
When a multi-item array uses associative keys, each value should start on a new line.

🪛 PHPMD (2.15.0)
includes/functions/settings-options.php

1274-1274: Avoid unused parameters such as '$args'. (undefined)

(UnusedFormalParameter)

🔇 Additional comments (3)
includes/AI/FormGenerator.php (1)

932-950: Provider dispatch helper looks correct and future‑proof

test_provider_api() cleanly dispatches by $this->current_provider and returns a standardized array, with an explicit “Unknown provider” branch. This is a nice central hook for additional providers later.

includes/functions/settings-options.php (2)

650-682: AI settings field definitions look consistent with intended UX

The new api_key_current callback field and temperature callback field are wired correctly (names, defaults, callbacks, descriptions), and the order (provider → key → model → temperature) matches the PR objective.


766-819: Fix Test Connection behavior for already‑saved keys and align model gating

The masking UI and per‑provider hidden key storage are well thought out, but there's a functional gap:

  • On initial load, when an API key already exists:
    • The masked display shows correctly.
    • #wpuf_ai_api_key_field is empty and hidden.
    • updateModelFieldState() is initialized with initialApiKey = $('#wpuf_ai_api_key_field').val(); (empty), so the model select is disabled and its description changed to "Please enter a valid API key…".
    • The Test Connection button uses var apiKey = $('#wpuf_ai_api_key_field').val(); (Line 1001–1002), so clicking it with a saved key but without pressing "Change" always yields "Please enter a valid API key", even though a valid key exists in the hidden input.

To make the UX match the spec ("test" should work with the configured key without forcing a change), I recommend:

  • Deriving the key for testing and model gating from the actual stored value, e.g.:
// When testing
var provider = $('input[name="wpuf_ai[ai_provider]"]:checked').val();
var apiKey = $('#wpuf_ai_' + provider + '_key').val() || $('#wpuf_ai_api_key_field').val();
  • Initializing state from the current provider's hidden key:
var initialProvider = $('input[name="wpuf_ai[ai_provider]"]:checked').val() || 'openai';
updateVisibleInputName(initialProvider);
updateApiKeyDisplay(initialProvider);
var initialApiKey = $('#wpuf_ai_' + initialProvider + '_key').val();
updateModelFieldState(initialApiKey);
$('#wpuf-test-connection-btn').prop('disabled', !initialApiKey || initialApiKey.trim().length < 10);

This keeps the masking behavior but lets admins test existing keys and have the model field correctly enabled when a valid key is already stored.

Comment on lines +98 to +100
// Using direct API implementation for AI providers

// Use manual AI provider implementation as fallback
switch ($this->current_provider) {
case 'openai':
$result = $this->generate_with_openai($prompt, $options);
break;

case 'anthropic':
$result = $this->generate_with_anthropic($prompt, $options);
break;

case 'google':
$result = $this->generate_with_google($prompt, $options);
break;

default:
throw new \Exception('Unsupported AI provider: ' . $this->current_provider);
}

// Restore original provider, model, and API key
$this->current_provider = $original_provider;
$this->current_model = $original_model;
$this->api_key = $original_api_key;

return $result;

} catch (\Exception $e) {
// Ensure full restoration even on exception
$this->current_provider = $original_provider ?? $this->current_provider;
$this->current_model = $original_model ?? $this->current_model;
$this->api_key = $original_api_key ?? $this->api_key;

return [
'success' => false,
'error' => true,
'message' => $e->getMessage(),
'provider' => $this->current_provider
];
}
}

/**
* Get model-specific parameter configuration
*
* This centralizes all model-specific parameter mappings for easy maintenance.
* Different AI providers and models have different parameter requirements:
*
* - Token parameters: 'max_tokens' vs 'max_completion_tokens' vs 'maxOutputTokens'
* - Temperature: Some models only support default temperature (1.0)
* - Response format: Some models don't support JSON mode
*
* @since 4.2.1
*
* @param string $provider Provider name (openai, anthropic, google)
* @param string $model Model name (e.g., gpt-5, claude-4.1-opus, gemini-2.5-pro)
* @return array Model configuration with parameter restrictions and requirements
*/
private function get_model_config($provider, $model) {
// Get configuration from centralized Config class
$config = Config::get_model_config($model);

// Return config if found, otherwise use safe defaults
if ($config !== null) {
return $config;
}

// Fallback defaults for each provider if model not found
$defaults = [
'openai' => [
'token_param' => 'max_tokens',
'token_location' => 'body',
'supports_json_mode' => true,
'supports_custom_temperature' => true
],
'anthropic' => [
'token_param' => 'max_tokens',
'token_location' => 'body',
'supports_json_mode' => true,
'supports_custom_temperature' => true
],
'google' => [
'token_param' => 'maxOutputTokens',
'token_location' => 'generationConfig',
'supports_json_mode' => true,
'supports_custom_temperature' => true
]
];

return $defaults[$provider] ?? $defaults['openai'];
}


/**
* Generate form using OpenAI
*
* @since 4.2.1
*
* @param string $prompt User prompt
* @param array $options Additional options
* @return array Generated form data
*/
private function generate_with_openai($prompt, $options = []) {
$context = $options['conversation_context'] ?? [];
$form_type = $options['form_type'] ?? 'post';
$language = $options['language'] ?? 'English';

// Add language to context for system prompt
$context['language'] = $language;

$system_prompt = $this->get_system_prompt($context, $form_type);

// Get model-specific configuration
$model_config = $this->get_model_config('openai', $this->current_model);

$body = [
'model' => $this->current_model,
'messages' => [
['role' => 'system', 'content' => $system_prompt],
['role' => 'user', 'content' => $prompt]
]
];

// Set temperature based on model capabilities
if ($model_config['supports_custom_temperature']) {
$body['temperature'] = floatval($options['temperature'] ?? 0.7);
} else {
// Use fixed temperature for models that don't support custom temperature
$body['temperature'] = $model_config['temperature'] ?? 1.0;
}

// Set response format based on model capabilities
if ($model_config['supports_json_mode']) {
$body['response_format'] = ['type' => 'json_object'];
} else {
// For models that don't support JSON mode, add explicit instruction to system prompt
$system_prompt .= "\n\nIMPORTANT: You must respond with ONLY valid JSON. Do not include any explanatory text, markdown formatting, or code blocks. Return ONLY the JSON object.";
// Update the message payload with the modified system prompt
$body['messages'][0]['content'] = $system_prompt;
}

// Set token parameter based on model
if ($model_config['token_location'] === 'body') {
// GPT-5 needs significantly more tokens for reasoning + output
if (strpos($this->current_model, 'gpt-5') === 0) {
$body[$model_config['token_param']] = intval($options['max_tokens'] ?? 65536);
} else {
$body[$model_config['token_param']] = intval($options['max_tokens'] ?? 2000);
}
}

$args = [
'method' => 'POST',
'headers' => [
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'application/json'
],
'body' => json_encode($body),
'timeout' => 120
];

$response = wp_safe_remote_request($this->provider_configs['openai']['endpoint'], $args);

if (is_wp_error($response)) {
$error_message = $response->get_error_message();

// Check for specific timeout errors
if (strpos($error_message, 'timeout') !== false || strpos($error_message, 'timed out') !== false) {
throw new \Exception('OpenAI API request timed out. Please try again later.');
}

throw new \Exception('OpenAI API request failed: ' . $error_message);
}

$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$error_body = wp_remote_retrieve_body($response);
throw new \Exception("OpenAI API returned HTTP {$status_code}: {$error_body}");
}

$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);

// Validate JSON response
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Invalid JSON response from AI provider: ' . json_last_error_msg());
}

if (isset($data['error'])) {
throw new \Exception('OpenAI API Error: ' . $data['error']['message']);
}

if (!isset($data['choices'][0]['message']['content'])) {
throw new \Exception('Invalid OpenAI response format. Response: ' . json_encode($data));
}

$content = $data['choices'][0]['message']['content'];

// Check for empty response
if (empty($content)) {
// Return error response instead of fallback
return [
'success' => false,
'error' => true,
'message' => 'AI model returned empty response. Please try again.',
'provider' => 'openai',
'model' => $this->current_model
];
}

// Clean and extract JSON from the response
$json_content = trim($content);

// Remove any markdown code blocks if present
$json_content = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $json_content);

// Remove any text before the first { or after the last }
$json_content = preg_replace('/^[^{]*/', '', $json_content);
$json_content = preg_replace('/[^}]*$/', '', $json_content);

// Try to find the JSON object (handle nested braces properly)
$start = strpos($json_content, '{');
$end = strrpos($json_content, '}');

if ($start !== false && $end !== false && $end > $start) {
$json_content = substr($json_content, $start, $end - $start + 1);
}

// Attempt to decode JSON
$ai_response = json_decode($json_content, true);

if (json_last_error() !== JSON_ERROR_NONE) {
return [
'success' => false,
'error' => true,
'message' => 'Unable to generate form. Please try again or rephrase your request.',
'provider' => 'openai',
'model' => $this->current_model
];
}

// Check for error response from AI
if ( ! empty( $ai_response['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $ai_response['message'] ?? 'AI returned an error response',
'provider' => 'openai',
'model' => $this->current_model
];
}

// Build complete form from minimal AI response
$form_data = Form_Builder::build_form( $ai_response );

// Check if form building failed
if ( ! empty( $form_data['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $form_data['message'] ?? 'Failed to build form structure',
'provider' => 'openai',
'model' => $this->current_model
];
}

// Add metadata with better uniqueness
$timestamp = microtime(true);
$random = bin2hex(random_bytes(5));
$form_data['session_id'] = $options['session_id'] ?? 'wpuf_ai_session_' . $timestamp . '_' . $random;
$form_data['response_id'] = 'openai_resp_' . $timestamp . '_' . $random;
$form_data['provider'] = 'openai';
$form_data['model'] = $this->current_model;
$form_data['generated_at'] = current_time('mysql');
$form_data['success'] = true;

return $form_data;
}

/**
* Generate form using Anthropic Claude
*
* @since 4.2.1
*
* @param string $prompt User prompt
* @param array $options Additional options
* @return array Generated form data
*/
private function generate_with_anthropic($prompt, $options = []) {
$context = $options['conversation_context'] ?? [];
$form_type = $options['form_type'] ?? 'post';
$language = $options['language'] ?? 'English';

// Add language to context for system prompt
$context['language'] = $language;

$system_prompt = $this->get_system_prompt($context, $form_type);

// Get model-specific configuration
$model_config = $this->get_model_config('anthropic', $this->current_model);

$body = [
'model' => $this->current_model,
'system' => $system_prompt,
'messages' => [
['role' => 'user', 'content' => $prompt]
]
];

// Set temperature based on model capabilities
if ($model_config['supports_custom_temperature']) {
$body['temperature'] = floatval($options['temperature'] ?? 0.7);
} else {
// Use fixed temperature for models that don't support custom temperature
$body['temperature'] = $model_config['temperature'] ?? 1.0;
}

// Set token parameter based on model
if ($model_config['token_location'] === 'body') {
// GPT-5 needs significantly more tokens for reasoning + output
if (strpos($this->current_model, 'gpt-5') === 0) {
$body[$model_config['token_param']] = intval($options['max_tokens'] ?? 65536);
} else {
$body[$model_config['token_param']] = intval($options['max_tokens'] ?? 2000);
}
}

$args = [
'method' => 'POST',
'headers' => [
'x-api-key' => $this->api_key,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json'
],
'body' => json_encode($body),
'timeout' => 120
];

$response = wp_safe_remote_request($this->provider_configs['anthropic']['endpoint'], $args);

if (is_wp_error($response)) {
throw new \Exception('Anthropic API request failed: ' . $response->get_error_message());
}

$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$error_body = wp_remote_retrieve_body($response);
throw new \Exception("Anthropic API returned HTTP {$status_code}: {$error_body}");
}

$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);

// Validate JSON response
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Invalid JSON response from Anthropic API: ' . json_last_error_msg());
}

if (isset($data['error'])) {
throw new \Exception('Anthropic API Error: ' . $data['error']['message']);
}

if (!isset($data['content'][0]['text'])) {
throw new \Exception('Invalid Anthropic response format');
}

$content = $data['content'][0]['text'];

// Clean and extract JSON from the response (Claude may include explanatory text)
$json_content = trim($content);
$json_content = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $json_content);
$json_content = preg_replace('/^[^{]*/', '', $json_content);
$json_content = preg_replace('/[^}]*$/', '', $json_content);

$start = strpos($json_content, '{');
$end = strrpos($json_content, '}');
if ($start !== false && $end !== false && $end > $start) {
$json_content = substr($json_content, $start, $end - $start + 1);
}

// Decode JSON
$ai_response = json_decode($json_content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Unable to generate form. Please try again or rephrase your request.');
}

// Check for error response from AI
if ( ! empty( $ai_response['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $ai_response['message'] ?? 'AI returned an error response',
'provider' => 'anthropic',
'model' => $this->current_model
];
}

// Build complete form from minimal AI response
$form_data = Form_Builder::build_form( $ai_response );

// Check if form building failed
if ( ! empty( $form_data['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $form_data['message'] ?? 'Failed to build form structure',
'provider' => 'anthropic',
'model' => $this->current_model
];
}

// Add metadata
$timestamp = microtime(true);
$random = bin2hex(random_bytes(5));
$form_data['session_id'] = $options['session_id'] ?? 'wpuf_ai_session_' . $timestamp . '_' . $random;
$form_data['response_id'] = 'anthropic_resp_' . $timestamp . '_' . $random;
$form_data['provider'] = 'anthropic';
$form_data['model'] = $this->current_model;
$form_data['generated_at'] = current_time('mysql');
$form_data['success'] = true;

return $form_data;
}


/**
* Generate form using Google Gemini
*
* @since 4.2.1
*
* @param string $prompt User prompt
* @param array $options Additional options
* @return array Generated form data
*/
private function generate_with_google($prompt, $options = []) {
$context = $options['conversation_context'] ?? [];
$form_type = $options['form_type'] ?? 'post';
$language = $options['language'] ?? 'English';

// Add language to context for system prompt
$context['language'] = $language;

$system_prompt = $this->get_system_prompt($context, $form_type);

// Get model-specific configuration
$model_config = $this->get_model_config('google', $this->current_model);

// Build endpoint with model
$endpoint = str_replace('{model}', $this->current_model, $this->provider_configs['google']['endpoint']);
$endpoint .= '?key=' . $this->api_key;

$body = [
'contents' => [
[
'parts' => [
['text' => $system_prompt . "\n\nUser request: " . $prompt]
]
]
],
'generationConfig' => []
];

// Set temperature based on model capabilities
if ($model_config['supports_custom_temperature']) {
$body['generationConfig']['temperature'] = floatval($options['temperature'] ?? 0.7);
} else {
// Use fixed temperature for models that don't support custom temperature
$body['generationConfig']['temperature'] = $model_config['temperature'] ?? 1.0;
}

// Set response format based on model capabilities
if ($model_config['supports_json_mode']) {
$body['generationConfig']['responseMimeType'] = 'application/json';
}

// Set token parameter based on model
if ($model_config['token_location'] === 'generationConfig') {
$body['generationConfig'][$model_config['token_param']] = intval($options['max_tokens'] ?? 2000);
}

$args = [
'method' => 'POST',
'headers' => [
'Content-Type' => 'application/json'
],
'body' => json_encode($body),
'timeout' => 120
];

$response = wp_safe_remote_request($endpoint, $args);

if (is_wp_error($response)) {
throw new \Exception('Google API request failed: ' . $response->get_error_message());
}

$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$error_body = wp_remote_retrieve_body($response);
throw new \Exception("Google API returned HTTP {$status_code}: {$error_body}");
}

$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);

// Validate JSON response
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Invalid JSON response from Google API: ' . json_last_error_msg());
}

if (isset($data['error'])) {
throw new \Exception('Google API Error: ' . ($data['error']['message'] ?? 'Unknown error'));
}

if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
throw new \Exception('Invalid Google response format');
}

$content = $data['candidates'][0]['content']['parts'][0]['text'];

// Clean and extract JSON from content
$json_content = trim($content);

// Remove any markdown code blocks if present
$json_content = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', $json_content);

// Remove any text before the first { or after the last }
$json_content = preg_replace('/^[^{]*/', '', $json_content);
$json_content = preg_replace('/[^}]*$/', '', $json_content);

// Try to find the JSON object (handle nested braces properly)
$start = strpos($json_content, '{');
$end = strrpos($json_content, '}');

if ($start !== false && $end !== false && $end > $start) {
$json_content = substr($json_content, $start, $end - $start + 1);
}

// Attempt to decode JSON
$ai_response = json_decode($json_content, true);

if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Unable to generate form. Please try again or rephrase your request.');
}

// Check for error response from AI
if ( ! empty( $ai_response['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $ai_response['message'] ?? 'AI returned an error response',
'provider' => 'google',
'model' => $this->current_model
];
}

// Build complete form from minimal AI response
$form_data = Form_Builder::build_form( $ai_response );

// Check if form building failed
if ( ! empty( $form_data['error'] ) ) {
return [
'success' => false,
'error' => true,
'message' => $form_data['message'] ?? 'Failed to build form structure',
'provider' => 'google',
'model' => $this->current_model
];
}

// Add metadata with better uniqueness
$timestamp = microtime(true);
$random = bin2hex(random_bytes(5));
$form_data['session_id'] = $options['session_id'] ?? 'wpuf_ai_session_' . $timestamp . '_' . $random;
$form_data['response_id'] = 'google_resp_' . $timestamp . '_' . $random;
$form_data['provider'] = 'google';
$form_data['model'] = $this->current_model;
$form_data['generated_at'] = current_time('mysql');
$form_data['success'] = true;

return $form_data;
}

/**
* Get system prompt for AI form generation
*
* @since 4.2.1
*
* @param array $context Conversation context
* @param string $form_type Form type ('post' or 'profile')
* @return string System prompt
*/
private function get_system_prompt($context = [], $form_type = 'post') {
// Determine which prompt file to use based on form type
if ( 'profile' === $form_type || 'registration' === $form_type ) {
// Registration/Profile form prompt - USE MINIMAL REGISTRATION PROMPT
$prompt_file = WPUF_ROOT . '/includes/AI/wpuf-ai-minimal-prompt-registration.md';
} else {
// Post form prompt - USE MINIMAL PROMPT
$prompt_file = WPUF_ROOT . '/includes/AI/wpuf-ai-minimal-prompt.md';
}

// Check if file exists
if ( ! file_exists( $prompt_file ) ) {
throw new \Exception( 'System prompt file not found: ' . $prompt_file );
}

// Load the prompt file
$system_prompt = file_get_contents( $prompt_file );

// Add form type context (informational, not restrictive)
$system_prompt .= "\n\n## FORM TYPE CONTEXT\n";
if ( 'profile' === $form_type || 'registration' === $form_type ) {
$system_prompt .= "You are working with a REGISTRATION/PROFILE form.\n";
$system_prompt .= "- Use registration/profile fields: user_email, user_login, password, first_name, last_name, biography, user_avatar, social fields, phone_field, address_field, dropdown_field, radio_field, checkbox_field, etc.\n";
$system_prompt .= "- Custom fields like dropdown, radio, checkbox, text fields are fully supported for additional profile information\n";
$system_prompt .= "- Focus on helping users collect user registration and profile data\n";
} else {
$system_prompt .= "You are working with a POST submission form.\n";
$system_prompt .= "- Use post fields: post_title, post_content, post_excerpt, featured_image, taxonomy, custom fields, etc.\n";
$system_prompt .= "- Focus on helping users collect content submission data\n";
}

// Add language context if provided or extract from user message
$target_language = $context['language'] ?? 'English';

// Extract language from user message if they're requesting conversion/translation
if ( ! empty( $context['chat_history'] ) && is_array( $context['chat_history'] ) && count( $context['chat_history'] ) > 0 ) {
$last_message = end( $context['chat_history'] );
$last_user_message = $last_message['content'] ?? '';

// Detect language conversion/translation requests
if ( preg_match( '/(?:convert|translate|change|make).*?(?:to|in|into)\s+(\w+)/i', $last_user_message, $matches ) ) {
$detected_language = ucfirst( strtolower( $matches[1] ) );
$target_language = $detected_language;
}
}

if ( ! empty( $target_language ) && $target_language !== 'English' ) {
$system_prompt .= "\n\n## TARGET LANGUAGE\n";
$system_prompt .= "**CRITICAL: The user has selected '{$target_language}' as their target language.**\n";
$system_prompt .= "- Generate ALL field labels in {$target_language}\n";
$system_prompt .= "- Generate ALL field placeholders in {$target_language}\n";
$system_prompt .= "- Generate ALL field help text in {$target_language}\n";
$system_prompt .= "- Generate ALL dropdown/radio/checkbox options in {$target_language}\n";
$system_prompt .= "- Generate form_title and form_description in {$target_language}\n";
$system_prompt .= "- When adding new fields, use {$target_language} for all text content\n";
$system_prompt .= "- Even if the user's message is in English, generate field content in {$target_language}\n";
$system_prompt .= "- This is a language conversion request - update ALL existing field labels to {$target_language}\n\n";
}

// Add conversation context if provided
if ( ! empty( $context ) ) {
$system_prompt .= "\n\n## CURRENT CONVERSATION CONTEXT\n";

// Safely extract last user message
$last_user_message = '';
if ( isset( $context['chat_history'] ) && is_array( $context['chat_history'] ) && count( $context['chat_history'] ) > 0 ) {
$last_message = end( $context['chat_history'] );
$last_user_message = $last_message['content'] ?? '';
}

// Determine modification intent
$modification_requested = false;
if ( isset( $context['modification_requested'] ) ) {
$modification_requested = (bool) $context['modification_requested'];
} else {
$modification_keywords = [ 'edit', 'modify', 'update', 'change', 'add', 'remove', 'delete', 'replace', 'convert', 'translate', 'make' ];
foreach ( $modification_keywords as $keyword ) {
if ( false !== stripos( $last_user_message, $keyword ) ) {
$modification_requested = true;
break;
}
}
}

// Build MINIMAL context - only send template + label, not full structures
$context_for_ai = [
'session_id' => $context['session_id'] ?? '',
'last_user_message' => $last_user_message,
'modification_requested' => $modification_requested,
'form_type' => $form_type, // Pass form type to AI for validation
];

// Include MINIMAL current fields (template + label + custom props only)
if ( ! empty( $context['current_form'] ) ) {
$minimal_fields = Form_Builder::extract_minimal_fields( $context['current_form'] );
if ( ! empty( $minimal_fields ) ) {
$context_for_ai['current_fields'] = $minimal_fields;
$context_for_ai['form_title'] = $context['current_form']['form_title'] ?? '';
$context_for_ai['form_description'] = $context['current_form']['form_description'] ?? '';
}
}

$system_prompt .= json_encode( $context_for_ai, JSON_PRETTY_PRINT );

// Add specific instruction for modifications
$system_prompt .= "\n\n## MODIFICATION INSTRUCTION\n";
if ( $modification_requested ) {
$system_prompt .= "The user wants to MODIFY the existing form. You MUST:\n";
$system_prompt .= "1. Return ALL existing fields from current_fields array (keep template + label + custom props)\n";
$system_prompt .= "2. Apply the requested modification (add/remove/edit specific fields)\n";
$system_prompt .= "3. Return COMPLETE field list with all fields in the 'fields' array\n";
$system_prompt .= "4. Include form_title and form_description from context\n\n";
$system_prompt .= "### Modification Types:\n";
$system_prompt .= "- **Add field**: Return existing fields + new field\n";
$system_prompt .= "- **Remove field**: Return existing fields without the specified field\n";
$system_prompt .= "- **Edit field**: Update the field's properties (label, required, placeholder, etc.)\n";
$system_prompt .= "- **Change field type**: Replace the field with a new template (e.g., checkbox_field → dropdown_field)\n";
$system_prompt .= " Example: 'change skills checkbox to dropdown' → Replace checkbox_field template with dropdown_field template\n\n";
$system_prompt .= "Examples:\n";
$system_prompt .= "- 'add email field' with 3 existing fields → return ALL 4 fields\n";
$system_prompt .= "- 'change skills from checkbox to dropdown' → return all fields with skills field having template: 'dropdown_field' instead of 'checkbox_field'\n";
} else {
$system_prompt .= "The user is asking a question. Return an error response with helpful message.";
}
}

return $system_prompt;
}


/**
* Get available providers
*
* @since 4.2.1
*
* @return array Provider configurations
*/
public function get_providers() {
return $this->provider_configs;
}

/**
* Get current provider
*
* @since 4.2.1
*
* @return string Current provider
*/
public function get_current_provider() {
return $this->current_provider;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp temperature server‑side and address minor coding‑standards issues

  • You correctly default temperature from wpuf_ai settings, but it’s only floatval()’d here. Consider clamping to [0.0, 1.0] at this call site as well (not just in the settings UI) so any malformed stored value can’t leak through to the provider APIs.
  • PHPCS hint: change get_option('wpuf_ai', []); to WordPress style with spaces: get_option( 'wpuf_ai', [] );.

Also applies to: 113-116

🧰 Tools
🪛 GitHub Check: Run PHPCS inspection

[failure] 99-99:
Expected 1 spaces before closing parenthesis; 0 found


[failure] 99-99:
Expected 1 spaces after opening parenthesis; 0 found

🤖 Prompt for AI Agents
In includes/AI/FormGenerator.php around lines 98-100 (and similarly at 113-116)
the call to get_option should use WordPress spacing and the retrieved
temperature must be clamped server-side; change get_option('wpuf_ai', []); to
get_option( 'wpuf_ai', [] ); then when reading the temperature, cast to float
and clamp it into the range 0.0–1.0 (e.g. use min(max(floatval(...), 0.0), 1.0))
before using or passing it to provider APIs so malformed stored values cannot
leak through.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs: dev review This PR needs review by a developer QA Approved This PR is approved by the QA team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement] Need test connection, temperature and token limit for AI settings

3 participants