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 +128 -8 1.0.01.1.10 View file →
@@ -20,8 +20,10 @@
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
@@ -119,8 +121,108 @@
119 121 return new Message( $message->getRole(), $kept );
120 122 }
121 123
122 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 +/**
123 225 * Runs one generation turn through the AI Client.
124 226 *
125 227 * Rebuilds the prompt from the full ordered message list each turn (the
126 228 * builder's `with_history()` prepends, so it can't append turns in a loop),
@@ -128,19 +230,18 @@
128 230 * answer to `$answer_schema` when given. Returns the assistant turn normalized
129 231 * to the shape the loop consumes; `message` has thought-channel parts stripped
130 232 * ({@see openstation_ai_strip_thought_parts()}) so it is safe to replay.
131 233 *
132 - * @param int $user_id Requesting user id. Currently unused — the
133 - * provider comes from Connectors and no
134 - * per-user preference is applied; retained for
135 - * signature stability and future attribution.
234 + * @param int $user_id Requesting user id.
136 235 * @param array $messages Ordered conversation as SDK Message objects.
137 236 * @param array $tool_defs Tool definitions to advertise.
138 237 * @param array|null $answer_schema JSON Schema for the final answer, or null.
139 238 * @param string $instructions System instruction.
239 + * @param array $context Optional. `{ source?: string, request_id?: string }`
240 + * for the model-config filter.
140 241 * @return array{ text: ?string, function_calls: array, message: mixed, usage: ?array, model: ?array }|WP_Error
141 242 */
142 -function openstation_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() ) {
143 244 $builder = wp_ai_client_prompt( $messages );
144 245
145 246 if ( is_string( $instructions ) && '' !== $instructions ) {
146 247 $builder = $builder->using_system_instruction( $instructions );
@@ -145,10 +246,10 @@
145 246 if ( is_string( $instructions ) && '' !== $instructions ) {
146 247 $builder = $builder->using_system_instruction( $instructions );
147 248 }
148 249
149 - // Provider + model selection is delegated entirely to the Core AI Client
150 - // (Connector-backed); OpenStation pins neither.
250 + // Provider + model selection is delegated to the Core AI Client
251 + // (Connector-backed) unless the model-config filter says otherwise.
151 252
152 253 $declarations = openstation_ai_build_function_declarations( $tool_defs );
153 254 if ( ! empty( $declarations ) ) {
154 255 $builder = $builder->using_function_declarations( ...$declarations );
@@ -160,8 +261,20 @@
160 261 // whole turn. Normalize here so no schema author has to know that.
161 262 $builder = $builder->as_json_response( openstation_ai_normalize_response_schema( $answer_schema ) );
162 263 }
163 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 +
164 277 $result = $builder->generate_result();
165 278 if ( is_wp_error( $result ) ) {
166 279 return $result;
167 280 }
@@ -185,12 +298,19 @@
185 298 }
186 299
187 300 $text = null;
188 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.
189 306 try {
190 307 $text = $result->toText();
191 308 } catch ( \Throwable $e ) {
192 - $text = null;
309 + return openstation_ai_empty_answer_error( $e->getMessage() );
310 + }
311 + if ( ! is_string( $text ) || '' === trim( $text ) ) {
312 + return openstation_ai_empty_answer_error( 'The provider response contains no text part.' );
193 313 }
194 314 }
195 315
196 316 return array(