Throw when no requested model preference is available#245
Conversation
Requesting a specific model id via usingModelPreference() that no resolved provider exposes previously fell through to the first discovered model with no signal, silently routing the request to an arbitrary (potentially far more expensive) model. A typo, stale id, or non-dated alias could route Haiku-priced traffic to Opus with no error. Generation now throws an InvalidArgumentException when explicit preferences were provided but none are available, naming the requested preferences (and provider, if locked) so the cause is obvious. Automatic discovery when no preference is set is unchanged, as is preference fallback among available preferences. Fixes WordPress#241. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## trunk #245 +/- ##
============================================
+ Coverage 88.12% 88.17% +0.04%
- Complexity 1213 1220 +7
============================================
Files 60 60
Lines 3934 3958 +24
============================================
+ Hits 3467 3490 +23
- Misses 467 468 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR changes PromptBuilder::usingModelPreference() behavior to fail loudly when explicit model preferences don’t match any available models, preventing silent fallback to discovery and potentially more expensive models (per issue #241).
Changes:
- Add unit tests asserting
usingModelPreference()throws when no requested preference is available (with and without a locked provider). - Update
PromptBuilderselection logic to throwInvalidArgumentExceptionwhen explicit preferences are provided but none match. - Add helper to build a clearer exception message listing requested preferences.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| tests/unit/Builders/PromptBuilderTest.php | Adds/updates tests to verify throwing behavior and keeps discovery behavior when no preference is set. |
| src/Builders/PromptBuilder.php | Implements “fail loudly” behavior for unmatched preferences and adds an exception-message builder. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Reconstructs a human-readable list of the requested preferences from their internal | ||
| * keys so a typo or stale model identifier is obvious to the caller. | ||
| * | ||
| * @since n.e.x.t |
There was a problem hiding this comment.
Thanks, but @since n.e.x.t is the project's documented convention for unreleased code — see CONTRIBUTING.md: "Use @since n.e.x.t for new code (will be replaced with actual version on release)." It's used consistently throughout the codebase and gets swapped for the real version at release time, so I've kept it as-is here for consistency.
| foreach ($this->modelPreferenceKeys as $preferenceKey) { | ||
| if (strpos($preferenceKey, 'providerModel::') === 0) { | ||
| [$providerId, $modelId] = explode('::', substr($preferenceKey, strlen('providerModel::')), 2); | ||
| $requested[] = sprintf('"%s" (provider "%s")', $modelId, $providerId); | ||
| } else { | ||
| $requested[] = sprintf('"%s"', substr($preferenceKey, strlen('model::'))); | ||
| } | ||
| } |
| 'None of the requested model preferences (%s) are available from provider "%s" for %s. ' | ||
| . 'Use a model identifier that the provider exposes, or call usingModelPreference() with an ' | ||
| . 'available model.', |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le branch. - Make buildUnmatchedPreferenceMessage() defensive against unexpected key shapes, falling back to the raw key instead of a misleading substring. - Reword the exception to point callers at removing usingModelPreference() for automatic discovery, instead of the circular "call it with an available model". - Add a unit test for an unavailable provider/model tuple preference, covering the provider-attributed message branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
felixarntz
left a comment
There was a problem hiding this comment.
@alewolf Thank you for the PR.
While I understand your concern here, the current behavior is by design. Since e.g. WordPress plugins aren't in control which providers and models a site has configured, the PHP AI Client is intended to work at the largest vector of sites - hence this API is about "model preferences", not "model requirements". So this is not a bug, it's intended behavior.
That being said, your concern is certainly valid. However, rather than addressing it as a bug, it needs to be an enhancement. The current change here would be breaking, so it can't be merged either way. What we could do instead is:
- either allow passing a
$strictflag tousingModelPreference() - or add a separate method
usingModelRequirement()(or maybe there's a better name to clarify the strict nature of this method) which is the same asusingModelPreference()but errors if no match is found
|
Thanks @felixarntz That makes sense. I agree that for a provider-agnostic SDK the preference API should stay best-effort, and that this is a breaking change as written.
I'd lean toward the second approach you mentioned, a separate method that mirrors I'll revert |
Summary
Fixes #241.
Requesting a specific model id via
usingModelPreference()that the resolved provider doesn't expose previously did not error — model resolution silently fell through to the first discovered model. A typo, a stale id, or a non-dated alias would silently route traffic to a potentially far more expensive model (e.g. Haiku → Opus) with no signal to the caller.Root cause
In
PromptBuilder::getConfiguredModel(), preferences are matched against the candidate map by exact string. When no preference matched, the code fell through toreset($candidateMap)— the first model discovered for the prompt's capability — with no error or warning.Fix
When explicit model preferences were provided but none are available from the resolved provider(s), generation now throws an
InvalidArgumentExceptioninstead of silently substituting a model. The message names the requested preference(s) (and the provider, if one was locked in viausingProvider()) so a typo or stale id is obvious.Unchanged behavior:
usingModel()→ the explicit path already validates the id and is unaffected.Behavior change note
This changes resolution from silent-fallback to throw for the unmatched-preference case. The unit test that codified the old silent fallback (
testUsingModelPreferenceFallsBackToDiscovery) has been updated accordingly. Maintainers may want to reflect this in the version bump (currently 1.3.1).Out of scope (per the issue)
claude-haiku-4-5→ latest dated model) — this mirrors provider-specific behavior and belongs in the individual provider plugins, not the core client.ModelMetadatacarries no pricing/cost data, so models can't be ranked by cost in core.Testing
composer test:unit— 1090 tests pass (addedtestUsingModelPreferenceThrowsWhenNoPreferenceAvailable,testUsingModelPreferenceThrowsWhenNoPreferenceAvailableForProvider, andtestFallsBackToDiscoveryWhenNoPreferenceSet).composer phpcs— clean.composer phpstan— no errors.🤖 Generated with Claude Code