Skip to content

Force a model to be passed when generating embeddings instead of relying on the model resolver - #274

Merged
jeffpaul merged 11 commits into
WordPress:trunkfrom
dkotter:update/embedding-model-resolver
Aug 31, 2026
Merged

Force a model to be passed when generating embeddings instead of relying on the model resolver#274
jeffpaul merged 11 commits into
WordPress:trunkfrom
dkotter:update/embedding-model-resolver

Conversation

@dkotter

@dkotter dkotter commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What?

Instead of relying on the ModelResolver to determine which model should be used when running the EmbeddingBuilder (which is what the PromptBuilder does), require a specific model to be passed in and if not, return an error.

Why?

Embedding vectors created by one model aren't compatible with vectors created by another model. For this reason it's important that a specific model is always provided instead of relying on a ModelResolver to determine this, which has a high likelihood of choosing a different model at various points.

The original version of embeddings (added in #244) closely followed what we already do in the PromptBuilder, but we realized that we should deviate slightly to force a model be passed. This PR makes that update and now an error will be returned if someone tries to use the EmbeddingBuilder without passing in a specific model.

How?

  • Modify our various generate methods to require a model be passed in
  • Extract some functionality out of the ModelResolutionTrait into a new ModelConfigurationTrait and use that in our EmbeddingBuilder
  • Ensure the EmbeddingBuilder sets the model properly and validates that the model is available and will work for the request
  • Add a new getUnmetRequirements method that will tell us all of the requirements that aren't met, allowing us to provide more detailed error messages

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Opus 5
Used for: Evaluating the existing code, iterating on a plan to make the above changes and then executing that plan. All code was reviewed and tested by me

Testing Instructions

Hard to test this PR on it's own as it requires an AI Provider that supports embeddings. Currently we have upstream PRs that add this support in but those haven't been released yet. Easiest way to test is the following:

  1. Remove the old packages. These have been renamed (these are fixed in Update the AI Provider plugins to their latest versions #275)
composer remove --dev --no-update \
  wordpress/anthropic-ai-provider \
  wordpress/google-ai-provider \
  wordpress/openai-ai-provider
  1. Configure the OpenAI fork
composer config repositories.ai-provider-for-openai vcs \
  https://github.com/chubes4/ai-provider-for-openai
  1. Install the providers from the right spot (specific branch or release)
composer require --dev \
  "wordpress/ai-provider-for-anthropic:^1.0" \
  "wordpress/ai-provider-for-google:dev-feature/embeddings" \
  "wordpress/ai-provider-for-openai:dev-feature/issue-32-openai-embeddings"
  1. Add a .env file and on one line, add GOOGLE_API_KEY=YOUR KEY HERE and on the next line add OPENAI_API_KEY=YOUR KEY HERE
  2. Run the integration test suite: composer test:integration (Note there are a couple errors with function calling tests but those are existing issues, not related to this PR)

After the above, you can also test directly using our cli.php file:

OPENAI_API_KEY=123456 php cli.php 'Your text here' --providerId=openai --modelId=text-embedding-3-small --outputFormat=embedding-json

You should see output on the command line that shows the embedding result.

Changelog Entry

Changed - Require a specific model be passed when generating embeddings instead of relying on a model resolver. Note this is a breaking change for anyone that has started to use embedding functionality and will require updates be made.

…ments are unmet so we can provide a more specific error message to a user
…ModelResolutionTrait to better support changes we need in the EmbeddingBuilder
…that will ensure the model provided is valid and will work for the request. Update our helpers in the AiClient to require a model be passed
@dkotter dkotter added this to the 1.4.0 milestone Aug 13, 2026
@dkotter dkotter self-assigned this Aug 13, 2026
@dkotter
dkotter requested a review from JasonTheAdams August 13, 2026 17:06
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: JasonTheAdams <jason_the_adams@git.wordpress.org>
Co-authored-by: ColinM-sys <colinmcdonough@git.wordpress.org>
Co-authored-by: chubes4 <extrachill@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.34177% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.54%. Comparing base (a31b0ec) to head (275fb47).
⚠️ Report is 2 commits behind head on trunk.

Files with missing lines Patch % Lines
src/Builders/EmbeddingBuilder.php 88.13% 14 Missing ⚠️
src/Builders/Traits/ModelConfigurationTrait.php 0.00% 6 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##              trunk     #274      +/-   ##
============================================
+ Coverage     86.49%   86.54%   +0.05%     
- Complexity     1327     1381      +54     
============================================
  Files            68       69       +1     
  Lines          4295     4438     +143     
============================================
+ Hits           3715     3841     +126     
- Misses          580      597      +17     
Flag Coverage Δ
unit 86.54% <87.34%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jeffpaul jeffpaul modified the milestones: 1.4.0, 1.5.0 Aug 13, 2026
…pecified, not if an invalid model was provided. This matches what the README claims, where someone can run isSupported to see if a model is supported without worrying about catching exceptions
…rate this out from prepareModel so we only touch model config prior to making a request, not verifying things

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the embedding-generation surface so callers must explicitly specify which model is used (instead of relying on resolver-based auto-selection), and enhances model requirement validation to produce more actionable errors. This aligns with embeddings’ constraint that vectors are only comparable within the same model.

Changes:

  • Require an explicit embedding model (via usingModel() or usingProviderModel()) and validate provider configuration + model capabilities/options before requesting embeddings.
  • Add ModelRequirements::getUnmetRequirements() to report all unsupported capabilities/options, enabling more detailed error messages.
  • Update unit/integration tests, CLI behavior, and documentation to reflect the explicit-model requirement and new failure modes.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/Providers/Models/DTO/ModelRequirementsTest.php Adds coverage for getUnmetRequirements() and additional areMetBy() edge cases.
tests/unit/Builders/EmbeddingBuilderTest.php Updates embedding builder tests for explicit model requirement, provider-config checks, and option validation behavior.
tests/unit/AiClientTest.php Updates traditional embedding API tests for the new required-model signature and tuple support.
tests/traits/MockModelCreationTrait.php Enhances embedding model metadata helper to declare realistic supported options by default.
tests/integration/OpenAi/EmbeddingGenerationIntegrationTest.php Updates OpenAI integration tests to always specify an embedding model and adds new negative tests.
tests/integration/Google/EmbeddingGenerationIntegrationTest.php Adds Google embedding integration tests with explicit model selection and negative cases.
src/Providers/Models/DTO/ModelRequirements.php Implements getUnmetRequirements() and refactors areMetBy() to use it.
src/Builders/Traits/ModelResolutionTrait.php Refactors trait to focus on model selection and delegate config handling to ModelConfigurationTrait.
src/Builders/Traits/ModelConfigurationTrait.php Introduces shared model-config accumulation/merging for builders.
src/Builders/EmbeddingBuilder.php Reworks builder to require and verify an explicitly named model (no resolver-based discovery).
src/AiClient.php Updates traditional embedding APIs to require a model and adds helper to configure an embedding builder.
README.md Updates embedding docs to require explicit model and explains why; adds discovery example.
docs/ARCHITECTURE.md Updates builder architecture explanation and embedding examples to reflect explicit model verification approach.
cli.php Requires --providerId + --modelId for embedding outputs and updates builder setup accordingly.
Suppressed comments (2)

src/AiClient.php:470

  • For consistency with generateEmbeddingResult() accepting a legacy 3rd-arg registry, generateEmbedding() should also accept the 3-argument form (input, model, registry) without a TypeError.
    public static function generateEmbedding(
        $input,
        $model,
        ?ModelConfig $modelConfig = null,
        ?ProviderRegistry $registry = null
    ): Embedding {
        return self::generateEmbeddingResult($input, $model, $modelConfig, $registry)->getEmbedding();

src/AiClient.php:496

  • generateEmbeddings() has the same avoidable TypeError risk for existing calls that pass a ProviderRegistry as the 3rd argument. If generateEmbeddingResult() supports the legacy argument ordering, this method should too.
    public static function generateEmbeddings(
        array $inputs,
        $model,
        ?ModelConfig $modelConfig = null,
        ?ProviderRegistry $registry = null
    ): array {
        return self::getConfiguredEmbeddingBuilder($inputs, $model, $modelConfig, $registry)
            ->generateEmbeddings();

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/AiClient.php
Comment on lines 438 to 446
public static function generateEmbeddingResult(
$input,
$modelOrConfig = null,
$model,
?ModelConfig $modelConfig = null,
?ProviderRegistry $registry = null
): EmbeddingResult {
self::validateModelOrConfigParameter($modelOrConfig);
return self::applyModelOrConfig(self::input($input, $registry), $modelOrConfig)
return self::getConfiguredEmbeddingBuilder($input, $model, $modelConfig, $registry)
->generateEmbeddingResult();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this is intentionally a breaking change

Comment thread docs/ARCHITECTURE.md
'PHP powers a large part of the web.',
'WordPress makes publishing accessible.',
])
->usingModel(GoogleProvider::model('gemini-embedding-001'))
@ColinM-sys

Copy link
Copy Markdown

Tested against a local Ollama (no API keys needed)

Since Fueled/ai-provider-for-ollama#69 shipped in 1.2.0, Ollama is a zero-cost way to exercise this PR end-to-end, so I ran it that way instead of the Google/OpenAI path in the testing instructions.

Setup

  • This branch @ 604a714 (8 commits on top of trunk a31b0ec), composer deps from lock.
  • fueled/ai-provider-for-ollama 1.2.0 (dev-develop 81bee69) installed as a Composer path dependency; registered via AiClient::defaultRegistry()->registerProvider(OllamaProvider::class).
  • Ollama 0.32.0 on the host; nomic-embed-text:latest (768-dim, /api/show reports capabilities: ["embedding"]) as the embedding model, llama3.1:8b (completion, tools) as the text-only negative case. OLLAMA_HOST + a dummy OLLAMA_API_KEY (the registry's {PROVIDER}_API_KEY env resolution).

Static / unit

  • phpunit --testsuite unit: OK (1190 tests, 4321 assertions) on PHP 7.4.33, 8.1.34, 8.4.24, 8.5.9.
  • phpstan analyse: no errors.

Live integration — 36/36 pass on PHP 8.5 and 7.4
I wrote a script that mirrors tests/integration/Google/EmbeddingGenerationIntegrationTest.php case-for-case against Ollama, then adds cases for the new builder behavior:

  • All 12 Google-test equivalents pass: single / list / withInput(), usingModel(OllamaProvider::model(...)) binds transport + auth, usingDimensions(256) is honored (the provider passes dimensions to /api/embed), batch 2→2, traditional API with [provider, model] tuple, result metadata (provider=ollama, model id, promptTokens=11), no-model rejected with the new message, text model rejected with Model "llama3.1:8b" from provider "ollama" does not support embedding generation., File input rejected locally (Unsupported options: inputModalities ([image]).), unknown model rejected.
  • isSupported(): true for the embedding model; false (no throw) for a text model, an unknown model id, an unconfigured provider (openai with no key) and an unregistered provider id; throws only when no model is set — matches the docblock.
  • Generating with an unconfigured provider throws the Provider "openai" is not registered, or is not configured with valid credentials message.
  • Config precedence: a model instance's own config (dimensions=128) is used when the builder sets none; builder usingDimensions(256) wins over instance config; isSupported() leaves a caller-provided instance's config untouched; replacing the model (usingModel() then usingProviderModel()) drops the old model's config — full 768 dims, no stale 128 leak.
  • __clone: changing dims on the clone doesn't affect the original; usingRequestOptions() is bound onto the model at generation time.
  • Traditional API: generateEmbeddings(list, [provider, model], ModelConfig{dims:128}) → 2×128; generateEmbeddingResult(input, ModelInterface) works; the old generateEmbedding($input, ModelConfig) and ($input, null) signatures are both rejected with the tuple/instance message.
  • Batch guard 3→3; same input twice → identical vectors; cosine sanity (related pair 0.653 > unrelated 0.392).

Trunk vs. this branch, same vendor + same Ollama: with no model given, AiClient::input($text)->generateEmbeddingResult() on trunk silently auto-selects ollama / nomic-embed-text:latest; on this branch it throws An embedding model must be specified…. That's the behavior change, working as intended.

cli.php: --outputFormat=embedding-result-json and embedding-json --dimensions=64 work; missing --modelId → the new CLI error; a text model → the builder's "does not support embedding generation" error; the --modelPreference ignored warning fires; the text-generation path is unaffected.

Full harness output (PHP 8.5.9)
php-ai-client 1.4.0 | PHP 8.5.9 | OLLAMA_HOST=http://host.docker.internal:11434
ollama configured: true

== Mirror of Google EmbeddingGenerationIntegrationTest ==
PASS  testSingleEmbeddingGeneration  — 768 dims
PASS  testSingleEmbeddingGenerationInputs (list input)  — 768 dims
PASS  testSingleEmbeddingGenerationWithInput (withInput)  — 768 dims
PASS  testEmbeddingGenerationWithModelInstance (usingModel binds deps)  — 768 dims
PASS  testEmbeddingGenerationWithDimensions (usingDimensions 256)  — 256 dims honored by Ollama /api/embed
PASS  testBatchEmbeddingGeneration (2 inputs, one vector each)  — 2 x 256
PASS  testTraditionalApiWithProviderModelTuple  — 768 dims
PASS  testEmbeddingResultMetadataAndTokenUsage  — provider=ollama model=nomic-embed-text:latest promptTokens=11
PASS  testGenerationWithoutModelIsRejected  — threw InvalidArgumentException: "An embedding model must be specified. ..."
PASS  testTextGenerationModelIsRejected (llama3.1:8b)  — threw InvalidArgumentException: "Model "llama3.1:8b" from provider "ollama" does not support embedding generation."
PASS  testFileInputIsRejected (local, no request)  — threw InvalidArgumentException: "Model "nomic-embed-text:latest" from provider "ollama" cannot fulfill this embedding request. Unsupported options: inputModalities ([image])."
PASS  testUnknownModelIsRejected  — threw InvalidArgumentException: "No model with ID definitely-not-a-real-model was found in the provider"

== Additional cases for #274's builder changes ==
PASS  isSupported(): true for embedding model
PASS  isSupported(): false for text model (no throw)
PASS  isSupported(): false for unknown model id (no throw)
PASS  isSupported(): false for unconfigured provider (openai, no key)
PASS  isSupported(): false for unregistered provider id
PASS  isSupported(): throws when no model set  — threw InvalidArgumentException: "An embedding model must be specified. ..."
PASS  generate with unconfigured provider throws clear message  — threw InvalidArgumentException: "Provider "openai" is not registered, or is not configured with valid credentials. ..."
PASS  usingProviderModel rejects empty provider / model ids  — both rejected
PASS  no inputs configured is rejected  — threw InvalidArgumentException: "Cannot generate embeddings from empty input. Add content using withInput()."
PASS  BREAKING: old signature generateEmbedding($input, ModelConfig) now rejected  — threw InvalidArgumentException: "Model must be a ModelInterface instance or a [provider ID, model ID] tuple. ..."
PASS  BREAKING: generateEmbedding($input, null) rejected  — threw InvalidArgumentException: "Model must be a ModelInterface instance or a [provider ID, model ID] tuple. ..."
PASS  traditional API: generateEmbeddings(list, tuple, ModelConfig dims=128)  — 2 x 128
PASS  traditional API: generateEmbeddingResult(input, ModelInterface instance)  — nomic-embed-text:latest, 768 dims
PASS  config precedence: model instance config dims=128 used when builder sets none  — 128
PASS  config precedence: builder usingDimensions(256) beats model instance config dims=128  — 256
PASS  isSupported() leaves a caller-provided model instance untouched  — instance dims still 128 after isSupported()
PASS  replacing the model replaces its config (usingModel then usingProviderModel)  — full 768 dims — stale 128 config did not leak
PASS  clone isolation: cloned builder dims do not affect original  — original 128, clone 256
PASS  usingRequestOptions is bound onto the API model at generation time  — connectTimeout=5.0 visible on model after generate
PASS  INFO: request-option timeout actually enforced by transport (connect timeout 1ms)  — no error — connect completed within 1ms; not a #274 concern
PASS  batch count guard: 3 inputs => 3 vectors  — 3
PASS  determinism: same input twice => identical vector  — identical
PASS  semantic sanity: related text scores higher than unrelated (cosine)  — related 0.653 > unrelated 0.392
PASS  INFO: bare model id without tag ("nomic-embed-text" vs ":latest")  — REJECTED — No model with ID nomic-embed-text was found in the provider

RESULT: 36 passed, 0 failed

Notes (none blocking)

  1. Downstream impact on the AI plugin. The EmbeddingBuilder public surface goes from usingModel, usingModelPreference, usingProvider on trunk to usingModel, usingProviderModel here. The AI plugin's generate_embeddings() wrapper in includes/helpers.php calls both removed methods ($args['provider']usingProvider(), $args['model_preference']usingModelPreference()), so bringing this back into the plugin (the Remove the loading of the SDK overlay class ai#946 follow-up tracked in Tracking: Implement Embedding Support ai#962) means changing that wrapper to take an explicit provider + model (e.g. $args['provider'] + $args['model']usingProviderModel()), and the semantic-search code in Feat: Add semantic search experiment to AI plugin ai#891 / #943 that passes model_preference => [ $model ] adjusts the same way. Expected given the changelog's breaking-change note — just flagging the exact call sites. Happy to pick that up as part of the #962 foundation work.
  2. Ollama-specific, not this PR: the Ollama provider's model directory keys models by the full name:tag, so usingProviderModel('ollama', 'nomic-embed-text') is rejected while 'nomic-embed-text:latest' works. Worth a line in that provider's docs; I can open that over on Fueled/ai-provider-for-ollama.
  3. Testing-instructions suggestion: with Fueled#69 released, Ollama is a no-key way to run this — composer config repositories.ollama vcs https://github.com/Fueled/ai-provider-for-ollama, composer require --dev fueled/ai-provider-for-ollama, then OLLAMA_API_KEY=ollama php cli.php '…' --providerId=ollama --modelId=nomic-embed-text:latest --outputFormat=embedding-json. If it'd be useful I can turn the run above into a tests/integration/Ollama/EmbeddingGenerationIntegrationTest.php that skips unless OLLAMA_HOST is set, as a follow-up PR.

Net: behaves exactly as described, no regressions found across PHP 7.4–8.5, and the only action item is the downstream wrapper change in the AI plugin.

@JasonTheAdams JasonTheAdams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you, @dkotter! I left a couple of suggestions.

Comment thread src/AiClient.php Outdated
Comment on lines +277 to +283
try {
$model = $this->locateModel();
} catch (InvalidArgumentException $e) {
// The model is unusable: its provider is not registered or configured, or the provider
// has no model with the given ID. Either way it cannot fulfill the request.
return false;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

isSupported()'s docblock promises that "any reason the specified model cannot fulfill the request is reported as false," but this catch only covers InvalidArgumentException. On the usingProviderModel() path, locateModel()getProviderModel()getModelMetadata() can trigger the provider's list-models HTTP request (when it isn't already cached by the availability check), and a transport failure there surfaces as a ResponseException — which extends RuntimeException, not InvalidArgumentException, so it escapes isSupported() despite the documented contract.

Since ResponseException and both custom exceptions implement AiClientExceptionInterface, catching that interface here would make the behavior match the docblock:

try {
    $model = $this->locateModel();
} catch (AiClientExceptionInterface $e) {
    // The model is unusable: its provider is not registered or configured, the provider has no
    // model with the given ID, or the provider could not be reached. Either way it cannot
    // fulfill the request.
    return false;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I missed that exception type. Addressed in ec5fdbc

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I noticed in your commit that you replaced InvalidArgumentException with the new exception. Did you mean to replace it or have it as both?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@JasonTheAdams Not sure I completely follow? I replaced InvalidArgumentException with AiClientExceptionInterface which I think matches your code example above. Are you suggesting to keep InvalidArgumentException and just add AiClientExceptionInterface as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, I didn't mean for my example to be an exact drop-in, but I can see how that was confusing. The isSupported() method is meant to always return false. We can either just catch all exceptions through Exception or list out each exceptions, which includes AiClientExceptionInterface and InvalidArgumentException (drilling into nested methods). It may be worth simply doing the former, but I'm not sure if we'd want to prevent unexpected exceptions from getting through. I don't think it's a big deal, so I'm good either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Parsing through our code, it appears that in almost all situations, catching AiClientExceptionInterface is what we'd want here. The one area where this isn't accurate is if a request fails with a status code that isn't in the 3xx-5xx range, that throws a native \RuntimeException and won't currently be caught.

So a couple options here as I see it:

  1. Widen the catch here to be just Exception, which catches that one edge case from our code and potential bugs from third party code. But that does mean we hide actual errors that may be better being surfaced
  2. Keep it as AiClientExceptionInterface and look to update ResponseUtil.php to return our custom RuntimeException instead of the native one, which then ensures all of our code is properly caught
  3. Keep it as AiClientExceptionInterface and not worry about edge cases?

Any preference on these @JasonTheAdams?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah, I was thinking about this wrong. Thanks for the clarification (and you as well, @ColinM-sys). Let's go with 3. The RuntimeException in ResponseUtil is very much a "what on earth?" final catch and should never be reached. If it is we want folks to figure out what happened.

@JasonTheAdams JasonTheAdams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Left to small questions, @dkotter, but I believe this is good to go once those are addressed. Please make sure to run the integration tests to confirm it's working.

Thanks for doing this!

@ColinM-sys

Copy link
Copy Markdown

Re-validated on 275fb47 (all three review-response commits included):

  • Unit suite: OK, 1191 tests / 4322 assertions (PHP 8.5); phpstan clean.
  • The 36-case live Ollama harness from my report above: 36/36, no regressions. (The official integration suite needs Google/OpenAI keys, so a local Ollama with nomic-embed-text remains my zero-key stand-in for it.)

On the open isSupported() catch question — some live data that may help settle it. What each failure mode actually throws on this branch (generate path, real Ollama provider):

Case Thrown AiClientExceptionInterface? isSupported()
Unregistered provider Common\Exception\InvalidArgumentException yes false
Registered, unconfigured (no key) Common\Exception\InvalidArgumentException yes false
Unknown model ID Common\Exception\InvalidArgumentException yes false
Text-generation model Common\Exception\InvalidArgumentException yes false
Unreachable host (cold start) Http\Exception\NetworkException yes false

The client's own InvalidArgumentException implements AiClientExceptionInterface (all of the client's exceptions do — the interface's docblock says that's its purpose), so the current single catch (AiClientExceptionInterface $e) already covers both exceptions named in this thread; nothing the client itself throws can escape. Broadening to \Exception would only additionally swallow non-client exceptions (e.g. a third-party library exception a provider implementation failed to wrap), which are arguably programming errors better surfaced than reported as "not supported."

One nuance that might merit a sentence in the docblock: because provider availability and model metadata are cached for the remainder of the request (as documented), a provider that was reachable earlier in the same request and then becomes unreachable still reports isSupported() === true — no HTTP happens on the later check. A cold start against an unreachable host correctly returns false. Consistent with the documented caching; just noting it so isSupported() isn't read as a liveness probe.

@jeffpaul
jeffpaul merged commit 20a1a6d into WordPress:trunk Aug 31, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement embedding generation contracts and client APIs

5 participants