PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / ai-copilot / openai.php

openai.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.1, at includes/ai-copilot/openai.php

463 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — OpenAI Responses API client.
4 *
5 * Uses the Responses API (POST /v1/responses) instead of Chat Completions.
6 * Key differences from Chat Completions:
7 *
8 * - `input` array (not `messages`), `instructions` for the system prompt.
9 * - Tool definitions are flat — no nested `function` wrapper.
10 * - Tool calls appear in `output[]` as `{ type: "function_call", call_id, name, arguments }`.
11 * - Tool results go back as `{ type: "function_call_output", call_id, output }` in `input`.
12 * - `previous_response_id` replaces full message-history replay for multi-turn loops.
13 * - Structured output lives under `text.format` (not top-level `response_format`).
14 *
15 * @package WPDesktopMode
16 */
17
18 defined( 'ABSPATH' ) || exit;
19
20 /** Default model. Filterable via `desktop_mode_ai_model`. */
21 const DESKTOP_MODE_AI_DEFAULT_MODEL = 'gpt-5.4-nano';
22
23 /** Responses API endpoint. */
24 const DESKTOP_MODE_AI_OPENAI_ENDPOINT = 'https://api.openai.com/v1/responses';
25
26 /** HTTP timeout in seconds. */
27 const DESKTOP_MODE_AI_REQUEST_TIMEOUT = 30;
28
29 // ---------------------------------------------------------------------------
30 // Shared HTTP layer
31 // ---------------------------------------------------------------------------
32
33 /**
34 * POST to the Responses API endpoint and return the decoded response array.
35 *
36 * All callers go through here so auth headers, timeout, and error handling
37 * live in one place.
38 *
39 * @since 0.14.0
40 *
41 * @param string $api_key OpenAI secret key.
42 * @param array $body Already-validated request body.
43 * @return array|WP_Error Decoded response or WP_Error.
44 */
45 function desktop_mode_ai_do_request( $api_key, array $body ) {
46 if ( empty( $api_key ) ) {
47 return new WP_Error( 'desktop_mode_ai_no_key', 'No OpenAI API key provided.' );
48 }
49
50 $encoded = wp_json_encode( $body );
51 if ( false === $encoded ) {
52 return new WP_Error( 'desktop_mode_ai_encode', 'Failed to JSON-encode request body.' );
53 }
54
55 // Narrowly-scoped execution-time bump: this is the ONE function
56 // that performs the slow OpenAI HTTP round-trip. The HTTP timeout
57 // (`DESKTOP_MODE_AI_REQUEST_TIMEOUT`, currently 30 s) equals PHP's
58 // default `max_execution_time`, so a near-timeout response could
59 // be killed by PHP before `wp_remote_post()` returns. The bump
60 // only applies to the request that reaches this line — every
61 // other code path runs under the host's normal limit. The `@`
62 // suppresses the warning on hosts where `set_time_limit()` is
63 // disabled in `disable_functions`; the request still proceeds
64 // under whatever limit the host enforces.
65 @set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
66
67 $http = wp_remote_post(
68 DESKTOP_MODE_AI_OPENAI_ENDPOINT,
69 array(
70 'timeout' => DESKTOP_MODE_AI_REQUEST_TIMEOUT,
71 'headers' => array(
72 'Content-Type' => 'application/json',
73 'Authorization' => 'Bearer ' . $api_key,
74 ),
75 'body' => $encoded,
76 )
77 );
78
79 if ( is_wp_error( $http ) ) {
80 return $http;
81 }
82
83 $code = (int) wp_remote_retrieve_response_code( $http );
84 $raw_body = wp_remote_retrieve_body( $http );
85 $decoded = json_decode( $raw_body, true );
86
87 if ( 200 !== $code ) {
88 $msg = isset( $decoded['error']['message'] )
89 ? (string) $decoded['error']['message']
90 : "OpenAI returned HTTP {$code}.";
91 return new WP_Error( 'desktop_mode_ai_api_error', $msg, array( 'status' => $code ) );
92 }
93
94 if ( ! is_array( $decoded ) ) {
95 return new WP_Error( 'desktop_mode_ai_parse', 'Could not parse OpenAI response body.' );
96 }
97
98 if ( isset( $decoded['status'] ) && 'failed' === $decoded['status'] ) {
99 $msg = $decoded['error']['message'] ?? 'Response failed with no error message.';
100 return new WP_Error( 'desktop_mode_ai_failed', $msg );
101 }
102
103 return $decoded;
104 }
105
106 // ---------------------------------------------------------------------------
107 // Output helpers
108 // ---------------------------------------------------------------------------
109
110 /**
111 * Extract the plain-text content from a Responses API response.
112 *
113 * The Responses API may return the text in any of several shapes depending
114 * on the model version and whether structured output is active:
115 *
116 * output[].type = "message"
117 * content[].type = "text" → standard shape
118 * content[].type = "output_text" → shape used when text.format is set
119 * output_text (top-level string) → compact shape on some endpoints
120 *
121 * We check all known locations so a future API change doesn't silently
122 * break text extraction.
123 *
124 * @since 0.14.0
125 *
126 * @param array $response Decoded Responses API response.
127 * @return string|null The text content, or null when absent.
128 */
129 function desktop_mode_ai_extract_text( array $response ) {
130 // 1. Walk output[] looking for message items.
131 foreach ( $response['output'] ?? array() as $item ) {
132 if ( ( $item['type'] ?? '' ) !== 'message' ) {
133 continue;
134 }
135 foreach ( $item['content'] ?? array() as $block ) {
136 // Accept both "text" and "output_text" block types.
137 if ( isset( $block['text'] ) && is_string( $block['text'] ) ) {
138 return $block['text'];
139 }
140 }
141 }
142
143 // 2. Top-level `output_text` shortcut (compact response shape).
144 if ( isset( $response['output_text'] ) && is_string( $response['output_text'] ) ) {
145 return $response['output_text'];
146 }
147
148 return null;
149 }
150
151 /**
152 * Return all `function_call` items from a Responses API output array.
153 *
154 * @since 0.14.0
155 *
156 * @param array $response Decoded Responses API response.
157 * @return array[] Array of function_call output items.
158 */
159 function desktop_mode_ai_extract_function_calls( array $response ) {
160 return array_values(
161 array_filter(
162 $response['output'] ?? array(),
163 static function ( $item ) {
164 return ( $item['type'] ?? '' ) === 'function_call';
165 }
166 )
167 );
168 }
169
170 // ---------------------------------------------------------------------------
171 // Structured-output (analysis jobs — no tools)
172 // ---------------------------------------------------------------------------
173
174 /**
175 * Send a single structured-output request to the Responses API.
176 *
177 * Used by the WP-Cron analysis jobs (post, term, comment). The caller
178 * passes a `$messages` array in Chat-Completions shape (with `role` keys);
179 * this function maps it to the Responses API shape:
180 *
181 * system message → `instructions`
182 * other messages → `input`
183 *
184 * @since 0.14.0
185 *
186 * @param string $api_key OpenAI secret key.
187 * @param array $messages Chat-style messages: `[['role'=>..., 'content'=>...], …]`.
188 * @param array $schema JSON Schema for the structured output.
189 * @param string $schema_name Identifier for the schema (snake_case, no spaces).
190 * @param string $model Model ID. Defaults to {@see DESKTOP_MODE_AI_DEFAULT_MODEL}.
191 * @return array|WP_Error Parsed JSON object or WP_Error.
192 */
193 function desktop_mode_ai_openai_structured_request(
194 $api_key,
195 array $messages,
196 array $schema,
197 $schema_name,
198 $model = DESKTOP_MODE_AI_DEFAULT_MODEL
199 ) {
200 /** @see desktop_mode_ai_model */
201 $model = (string) apply_filters( 'desktop_mode_ai_model', $model, $schema_name );
202
203 // Separate the system message from the user/assistant turns.
204 $instructions = '';
205 $input = array();
206 foreach ( $messages as $msg ) {
207 if ( isset( $msg['role'] ) && 'system' === $msg['role'] ) {
208 $instructions = (string) ( $msg['content'] ?? '' );
209 } else {
210 $input[] = $msg;
211 }
212 }
213
214 $body = array(
215 'model' => $model,
216 'input' => $input,
217 'text' => array(
218 'format' => array(
219 'type' => 'json_schema',
220 'name' => $schema_name,
221 'strict' => true,
222 'schema' => $schema,
223 ),
224 ),
225 );
226 if ( $instructions !== '' ) {
227 $body['instructions'] = $instructions;
228 }
229
230 $response = desktop_mode_ai_do_request( $api_key, $body );
231 if ( is_wp_error( $response ) ) {
232 return $response;
233 }
234
235 $text = desktop_mode_ai_extract_text( $response );
236 if ( ! is_string( $text ) ) {
237 return new WP_Error( 'desktop_mode_ai_empty', 'Responses API returned no text content.' );
238 }
239
240 $result = json_decode( $text, true );
241 if ( ! is_array( $result ) ) {
242 return new WP_Error( 'desktop_mode_ai_result_parse', 'Could not parse structured output JSON.' );
243 }
244
245 return $result;
246 }
247
248 // ---------------------------------------------------------------------------
249 // Agentic / multi-turn (search loop)
250 // ---------------------------------------------------------------------------
251
252 /**
253 * Make one turn of a Responses API agentic loop.
254 *
255 * On the first turn `$previous_response_id` is null and `$input` contains
256 * the user message. On subsequent turns `$previous_response_id` carries
257 * the id from the previous response and `$input` contains only the
258 * `function_call_output` items — the Responses API reconstructs the full
259 * context from the chain automatically.
260 *
261 * @since 0.14.0
262 *
263 * @param string $api_key OpenAI secret key.
264 * @param array $input Input items for this turn.
265 * @param array $tools Tool definitions (flat Responses API format).
266 * @param array|null $text_format Optional `text.format` object for structured output.
267 * @param string $instructions Optional system-level instructions.
268 * @param string|null $previous_response_id Chains this call to a previous response.
269 * @return array|WP_Error
270 */
271 function desktop_mode_ai_openai_responses_call(
272 $api_key,
273 array $input,
274 array $tools = array(),
275 $text_format = null,
276 $instructions = '',
277 $previous_response_id = null
278 ) {
279 $model = (string) apply_filters( 'desktop_mode_ai_model', DESKTOP_MODE_AI_DEFAULT_MODEL, 'agentic_search' );
280
281 $body = array(
282 'model' => $model,
283 'input' => $input,
284 );
285
286 if ( $instructions !== '' ) {
287 $body['instructions'] = $instructions;
288 }
289
290 if ( ! empty( $tools ) ) {
291 $body['tools'] = $tools;
292 $body['tool_choice'] = 'auto';
293 }
294
295 if ( is_array( $text_format ) ) {
296 $body['text'] = array( 'format' => $text_format );
297 }
298
299 if ( is_string( $previous_response_id ) && $previous_response_id !== '' ) {
300 $body['previous_response_id'] = $previous_response_id;
301 }
302
303 return desktop_mode_ai_do_request( $api_key, $body );
304 }
305
306 // ---------------------------------------------------------------------------
307 // Provider-registry adapters
308 //
309 // These wire the OpenAI implementation as the built-in default provider.
310 // Each callback simply translates between the registry's normalized shape
311 // and the existing OpenAI helpers above. New providers (Anthropic, Gemini,
312 // local LLMs, …) ship their own equivalents and register the same way.
313 // ---------------------------------------------------------------------------
314
315 /**
316 * Build an opaque "turn input" for the OpenAI provider.
317 *
318 * For the Responses API a turn input is the `input` array. We accept two
319 * kinds — a fresh user message, or a batch of tool results to resume an
320 * agentic loop with `previous_response_id` already in state.
321 *
322 * @since 0.18.0
323 *
324 * @param string $kind 'user_message' | 'tool_results'.
325 * @param mixed $payload For 'user_message': string. For 'tool_results':
326 * array of [{ call_id, output (json string) }, …].
327 * @return array Input items array, ready for the Responses API.
328 */
329 function desktop_mode_ai_openai_make_turn_input( $kind, $payload ) {
330 if ( 'user_message' === $kind ) {
331 return array(
332 array(
333 'role' => 'user',
334 'content' => is_string( $payload ) ? $payload : (string) wp_json_encode( $payload ),
335 ),
336 );
337 }
338
339 if ( 'tool_results' === $kind && is_array( $payload ) ) {
340 $items = array();
341 foreach ( $payload as $r ) {
342 if ( ! is_array( $r ) ) {
343 continue;
344 }
345 $items[] = array(
346 'type' => 'function_call_output',
347 'call_id' => isset( $r['call_id'] ) ? (string) $r['call_id'] : '',
348 'output' => isset( $r['output'] ) ? (string) $r['output'] : '',
349 );
350 }
351 return $items;
352 }
353
354 return array();
355 }
356
357 /**
358 * One turn of the agentic loop via the OpenAI Responses API.
359 *
360 * @since 0.18.0
361 *
362 * @param string $api_key OpenAI key.
363 * @param array $turn_input Output of {@see desktop_mode_ai_openai_make_turn_input}.
364 * @param array $tools Responses-API tool definitions (flat shape).
365 * @param array|null $text_format Optional `text.format` object for structured output.
366 * @param string $instructions System-level instructions.
367 * @param mixed $state Provider state ['previous_response_id' => …] | null.
368 * @return array|WP_Error
369 */
370 function desktop_mode_ai_openai_provider_agentic_call(
371 $api_key,
372 $turn_input,
373 array $tools,
374 $text_format,
375 $instructions,
376 $state = null
377 ) {
378 $previous_response_id = '';
379 if ( is_array( $state ) && ! empty( $state['previous_response_id'] ) ) {
380 $previous_response_id = (string) $state['previous_response_id'];
381 }
382
383 $response = desktop_mode_ai_openai_responses_call(
384 $api_key,
385 is_array( $turn_input ) ? $turn_input : array(),
386 $tools,
387 $text_format,
388 $instructions,
389 $previous_response_id
390 );
391 if ( is_wp_error( $response ) ) {
392 return $response;
393 }
394
395 // Normalize function-call shape — registry contract is { name, call_id, arguments (string) }.
396 $function_calls = array();
397 foreach ( desktop_mode_ai_extract_function_calls( $response ) as $fc ) {
398 $function_calls[] = array(
399 'name' => isset( $fc['name'] ) ? (string) $fc['name'] : '',
400 'call_id' => isset( $fc['call_id'] ) ? (string) $fc['call_id'] : '',
401 'arguments' => isset( $fc['arguments'] ) ? (string) $fc['arguments'] : '',
402 );
403 }
404
405 return array(
406 'text' => desktop_mode_ai_extract_text( $response ),
407 'function_calls' => $function_calls,
408 'next_state' => array( 'previous_response_id' => isset( $response['id'] ) ? (string) $response['id'] : '' ),
409 'raw' => $response,
410 );
411 }
412
413 /**
414 * OpenAI provider — single-shot structured output adapter.
415 *
416 * Thin wrapper that selects a default model when the caller passes ''.
417 *
418 * @since 0.18.0
419 *
420 * @param string $api_key
421 * @param array $messages
422 * @param array $schema
423 * @param string $schema_name
424 * @param string $model Empty string → use the provider default.
425 * @return array|WP_Error
426 */
427 function desktop_mode_ai_openai_provider_structured_request(
428 $api_key,
429 array $messages,
430 array $schema,
431 $schema_name,
432 $model = ''
433 ) {
434 $model = '' === (string) $model ? DESKTOP_MODE_AI_DEFAULT_MODEL : (string) $model;
435 return desktop_mode_ai_openai_structured_request( $api_key, $messages, $schema, (string) $schema_name, $model );
436 }
437
438 /**
439 * Register the built-in OpenAI provider.
440 *
441 * Registration runs on `desktop_mode_ai_register_providers` (fired lazily on
442 * first lookup) so the registry doesn't depend on plugin load order.
443 *
444 * @since 0.18.0
445 */
446 function desktop_mode_ai_register_openai_provider() {
447 desktop_mode_register_ai_provider(
448 'openai',
449 array(
450 'label' => 'OpenAI',
451 'description' => 'OpenAI Responses API (gpt-5 family). Default provider.',
452 'api_key_label' => 'OpenAI API key',
453 'api_key_link' => 'https://platform.openai.com/api-keys',
454 'default_model' => DESKTOP_MODE_AI_DEFAULT_MODEL,
455 'capabilities' => array( 'tools', 'structured_output', 'previous_response_id' ),
456 'make_turn_input' => 'desktop_mode_ai_openai_make_turn_input',
457 'agentic_call' => 'desktop_mode_ai_openai_provider_agentic_call',
458 'structured_request' => 'desktop_mode_ai_openai_provider_structured_request',
459 )
460 );
461 }
462 add_action( 'desktop_mode_ai_register_providers', 'desktop_mode_ai_register_openai_provider' );
463