PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/ai-copilot/client.php +146 -37 0.9.61.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — AI Copilot: WordPress AI Client adapter.
3 + * OpenStation — AI Copilot: WordPress AI Client adapter.
4 4 *
5 5 * Thin wrappers around `wp_ai_client_prompt()` that the agentic search loop
6 6 * and the comment-scoring job use to generate. Credentials are injected by
7 7 * Core from the configured Connector — nothing here ever handles an API key.
@@ -11,17 +11,19 @@
11 11 * dispatches ability calls through `wp_get_ability()->execute()`.
12 12 *
13 13 * All SDK classes referenced here ship with WordPress 7.0+. The `use`
14 14 * statements are compile-time aliases only; every call site is reached solely
15 - * through {@see desktop_mode_ai_is_available()}, so this file is inert (and
15 + * through {@see openstation_ai_is_available()}, so this file is inert (and
16 16 * never resolves the classes) on older WordPress.
17 17 *
18 - * @package WPDesktopMode
18 + * @package OpenStation
19 19 */
20 20
21 21 use WordPress\AiClient\Messages\DTO\Message;
22 22 use WordPress\AiClient\Messages\DTO\MessagePart;
23 23 use WordPress\AiClient\Messages\DTO\UserMessage;
24 +use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
25 +use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
24 26 use WordPress\AiClient\Tools\DTO\FunctionCall;
25 27 use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
26 28 use WordPress\AiClient\Tools\DTO\FunctionResponse;
27 29
@@ -32,14 +34,12 @@
32 34 *
33 35 * Each definition is the neutral tool shape the registry already produces:
34 36 * `{ type: 'function', name, description, parameters (JSON Schema) }`.
35 37 *
36 - * @since 0.9.4
37 - *
38 38 * @param array $tool_defs List of tool definitions.
39 39 * @return FunctionDeclaration[]
40 40 */
41 -function desktop_mode_ai_build_function_declarations( array $tool_defs ) {
41 +function openstation_ai_build_function_declarations( array $tool_defs ) {
42 42 $declarations = array();
43 43 foreach ( $tool_defs as $def ) {
44 44 if ( ! is_array( $def ) ) {
45 45 continue;
@@ -58,14 +58,12 @@
58 58
59 59 /**
60 60 * Wraps a user query as a text message for the conversation history.
61 61 *
62 - * @since 0.9.4
63 - *
64 62 * @param string $text
65 63 * @return UserMessage
66 64 */
67 -function desktop_mode_ai_user_text_message( $text ) {
65 +function openstation_ai_user_text_message( $text ) {
68 66 return new UserMessage( array( new MessagePart( (string) $text ) ) );
69 67 }
70 68
71 69 /**
@@ -70,14 +68,12 @@
70 68
71 69 /**
72 70 * Wraps tool results as a user message of function-response parts.
73 71 *
74 - * @since 0.9.4
75 - *
76 72 * @param array $tool_outputs List of `{ call_id, name, response }` entries.
77 73 * @return UserMessage
78 74 */
79 -function desktop_mode_ai_tool_result_message( array $tool_outputs ) {
75 +function openstation_ai_tool_result_message( array $tool_outputs ) {
80 76 $parts = array();
81 77 foreach ( $tool_outputs as $output ) {
82 78 $parts[] = new MessagePart(
83 79 new FunctionResponse(
@@ -103,14 +99,12 @@
103 99 * If every part is a thought (no text, no function call), the message is
104 100 * returned unchanged rather than emptied; the loop never replays such a
105 101 * turn anyway.
106 102 *
107 - * @since 0.9.4
108 - *
109 103 * @param Message $message Assistant message as returned by the AI Client.
110 104 * @return Message Message safe to append to the conversation history.
111 105 */
112 -function desktop_mode_ai_strip_thought_parts( Message $message ) {
106 +function openstation_ai_strip_thought_parts( Message $message ) {
113 107 $kept = array();
114 108 $stripped = false;
115 109 foreach ( $message->getParts() as $part ) {
116 110 if ( $part->getChannel()->isThought() ) {
@@ -127,8 +121,108 @@
127 121 return new Message( $message->getRole(), $kept );
128 122 }
129 123
130 124 /**
125 + * Builds the error for a final turn that produced no answer text.
126 + *
127 + * Observed live with the Anthropic provider under agent runs: a hard task
128 + * spends the entire `max_tokens` budget inside a thinking block
129 + * (`stop_reason: "max_tokens"`, a single text-less thought part), so the
130 + * turn carries neither function calls nor extractable text. Callers that
131 + * can meaningfully degrade instead (the command follow-up turn) match on
132 + * this code and keep their own fallback.
133 + *
134 + * @param string $detail Underlying extraction failure, preserved for logs.
135 + * @return WP_Error
136 + */
137 +function openstation_ai_empty_answer_error( $detail ) {
138 + return new WP_Error(
139 + 'openstation_ai_empty_answer',
140 + __( 'The AI provider returned no answer text.', 'desktop-mode' ),
141 + array(
142 + 'status' => 502,
143 + 'detail' => (string) $detail,
144 + )
145 + );
146 +}
147 +
148 +/**
149 + * Applies the site's model config to a prompt builder.
150 + *
151 + * @param mixed $builder WP_AI_Client_Prompt_Builder.
152 + * @param array $context Partial filter context; missing keys are defaulted.
153 + * @return mixed
154 + */
155 +function openstation_ai_apply_model_config( $builder, array $context ) {
156 + $context = array_merge(
157 + array(
158 + 'user_id' => 0,
159 + 'request_id' => '',
160 + 'source' => '',
161 + 'has_tools' => false,
162 + 'has_schema' => false,
163 + ),
164 + $context
165 + );
166 +
167 + /**
168 + * Filters the model config for one AI turn.
169 + *
170 + * Defaults to empty. Recipe: `docs/examples/ai-model-config.md`.
171 + *
172 + * @param array $config { model?: string|ModelInterface, max_tokens?: int, temperature?: float, custom_options?: array<string, mixed> }.
173 + * @param array $context { user_id, request_id, source, has_tools, has_schema }.
174 + */
175 + $config = apply_filters( 'openstation_ai_model_config', array(), $context );
176 + if ( ! is_array( $config ) ) {
177 + return $builder;
178 + }
179 +
180 + $model_config = new ModelConfig();
181 +
182 + if ( isset( $config['max_tokens'] ) && is_numeric( $config['max_tokens'] ) && (int) $config['max_tokens'] > 0 ) {
183 + $model_config->setMaxTokens( (int) $config['max_tokens'] );
184 + }
185 +
186 + // Unlike max_tokens, 0.0 is a legitimate temperature (deterministic). The
187 + // 2.0 ceiling is the range the SDK's own schema declares.
188 + if ( isset( $config['temperature'] ) && is_numeric( $config['temperature'] )
189 + && (float) $config['temperature'] >= 0.0 && (float) $config['temperature'] <= 2.0 ) {
190 + $model_config->setTemperature( (float) $config['temperature'] );
191 + }
192 +
193 + $custom_options = array();
194 + if ( isset( $config['custom_options'] ) && is_array( $config['custom_options'] ) ) {
195 + foreach ( $config['custom_options'] as $key => $value ) {
196 + // A list would reach the provider as parameters named `0`, `1`, ….
197 + if ( is_string( $key ) && '' !== $key ) {
198 + $custom_options[ $key ] = $value;
199 + }
200 + }
201 + }
202 +
203 + if ( ! empty( $custom_options ) ) {
204 + $model_config->setCustomOptions( $custom_options );
205 + }
206 +
207 + $builder = $builder->using_model_config( $model_config );
208 +
209 + // After the config: `using_model()` merges the model's own defaults under
210 + // whatever the builder already carries, so ours has to land first.
211 + $model = isset( $config['model'] ) ? $config['model'] : null;
212 + if ( $model instanceof ModelInterface ) {
213 + $builder = $builder->using_model( $model );
214 + } elseif ( is_string( $model ) && '' !== trim( $model ) ) {
215 + // `using_model()` needs a ModelInterface, so a bare model id goes
216 + // through `using_model_preference()`, which throws on anything that
217 + // isn't a non-empty string.
218 + $builder = $builder->using_model_preference( trim( $model ) );
219 + }
220 +
221 + return $builder;
222 +}
223 +
224 +/**
131 225 * Runs one generation turn through the AI Client.
132 226 *
133 227 * Rebuilds the prompt from the full ordered message list each turn (the
134 228 * builder's `with_history()` prepends, so it can't append turns in a loop),
@@ -134,23 +228,20 @@
134 228 * builder's `with_history()` prepends, so it can't append turns in a loop),
135 229 * advertises the tools as function declarations, and constrains the final
136 230 * answer to `$answer_schema` when given. Returns the assistant turn normalized
137 231 * to the shape the loop consumes; `message` has thought-channel parts stripped
138 - * ({@see desktop_mode_ai_strip_thought_parts()}) so it is safe to replay.
232 + * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
139 233 *
140 - * @since 0.9.4
141 - *
142 - * @param int $user_id Requesting user id. Currently unused — the
143 - * provider comes from Connectors and no
144 - * per-user preference is applied; retained for
145 - * signature stability and future attribution.
234 + * @param int $user_id Requesting user id.
146 235 * @param array $messages Ordered conversation as SDK Message objects.
147 236 * @param array $tool_defs Tool definitions to advertise.
148 237 * @param array|null $answer_schema JSON Schema for the final answer, or null.
149 238 * @param string $instructions System instruction.
239 + * @param array $context Optional. `{ source?: string, request_id?: string }`
240 + * for the model-config filter.
150 241 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
151 242 */
152 -function desktop_mode_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions ) {
243 +function openstation_ai_client_generate( $user_id, array $messages, array $tool_defs, $answer_schema, $instructions, array $context = array() ) {
153 244 $builder = wp_ai_client_prompt( $messages );
154 245
155 246 if ( is_string( $instructions ) && '' !== $instructions ) {
156 247 $builder = $builder->using_system_instruction( $instructions );
@@ -155,20 +246,35 @@
155 246 if ( is_string( $instructions ) && '' !== $instructions ) {
156 247 $builder = $builder->using_system_instruction( $instructions );
157 248 }
158 249
159 - // Provider + model selection is delegated entirely to the Core AI Client
160 - // (Connector-backed); Desktop Mode pins neither.
250 + // Provider + model selection is delegated to the Core AI Client
251 + // (Connector-backed) unless the model-config filter says otherwise.
161 252
162 - $declarations = desktop_mode_ai_build_function_declarations( $tool_defs );
253 + $declarations = openstation_ai_build_function_declarations( $tool_defs );
163 254 if ( ! empty( $declarations ) ) {
164 255 $builder = $builder->using_function_declarations( ...$declarations );
165 256 }
166 257
167 258 if ( is_array( $answer_schema ) ) {
168 - $builder = $builder->as_json_response( $answer_schema );
259 + // Strict structured output: providers reject an object subschema that
260 + // doesn't set `additionalProperties: false`, and one such node 400s the
261 + // whole turn. Normalize here so no schema author has to know that.
262 + $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
169 263 }
170 264
265 + $builder = openstation_ai_apply_model_config(
266 + $builder,
267 + array_merge(
268 + $context,
269 + array(
270 + 'user_id' => (int) $user_id,
271 + 'has_tools' => ! empty( $declarations ),
272 + 'has_schema' => is_array( $answer_schema ),
273 + )
274 + )
275 + );
276 +
171 277 $result = $builder->generate_result();
172 278 if ( is_wp_error( $result ) ) {
173 279 return $result;
174 280 }
@@ -192,21 +298,28 @@
192 298 }
193 299
194 300 $text = null;
195 301 if ( empty( $function_calls ) ) {
302 + // A turn with no function calls IS the final answer, so failing to
303 + // extract its text is a failed generation, not a valid empty one.
304 + // Swallowing it here used to surface as a "successful" run with an
305 + // empty answer, invisible to the retry and error paths alike.
196 306 try {
197 307 $text = $result->toText();
198 308 } catch ( \Throwable $e ) {
199 - $text = null;
309 + return openstation_ai_empty_answer_error( $e->getMessage() );
200 310 }
311 + if ( ! is_string( $text ) || '' === trim( $text ) ) {
312 + return openstation_ai_empty_answer_error( 'The provider response contains no text part.' );
313 + }
201 314 }
202 315
203 316 return array(
204 317 'text' => $text,
205 318 'function_calls' => $function_calls,
206 - 'message' => desktop_mode_ai_strip_thought_parts( $message ),
207 - 'usage' => desktop_mode_ai_result_token_usage( $result ),
208 - 'model' => desktop_mode_ai_result_model_metadata( $result ),
319 + 'message' => openstation_ai_strip_thought_parts( $message ),
320 + 'usage' => openstation_ai_result_token_usage( $result ),
321 + 'model' => openstation_ai_result_model_metadata( $result ),
209 322 );
210 323 }
211 324
212 325 /**
@@ -211,14 +324,12 @@
211 324
212 325 /**
213 326 * Extracts normalized token usage from a generation result.
214 327 *
215 - * @since 0.9.4
216 - *
217 328 * @param mixed $result GenerativeAiResult.
218 329 * @return array{ prompt: int, completion: int, total: int }|null
219 330 */
220 -function desktop_mode_ai_result_token_usage( $result ) {
331 +function openstation_ai_result_token_usage( $result ) {
221 332 try {
222 333 $usage = $result->getTokenUsage();
223 334 return array(
224 335 'prompt' => (int) $usage->getPromptTokens(),
@@ -232,14 +343,12 @@
232 343
233 344 /**
234 345 * Extracts the resolved model's id + name from a generation result.
235 346 *
236 - * @since 0.9.4
237 - *
238 347 * @param mixed $result GenerativeAiResult.
239 348 * @return array{ id: string, name: string }|null
240 349 */
241 -function desktop_mode_ai_result_model_metadata( $result ) {
350 +function openstation_ai_result_model_metadata( $result ) {
242 351 try {
243 352 $model = $result->getModelMetadata();
244 353 return array(
245 354 'id' => (string) $model->getId(),