Skip to content

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

Open
dkotter wants to merge 10 commits into
WordPress:trunkfrom
dkotter:update/embedding-model-resolver
Open

Force a model to be passed when generating embeddings instead of relying on the model resolver#274
dkotter wants to merge 10 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 (ec5fdbc).
⚠️ 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
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an

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.

Instead of doing array{0: string, 1: string} in various places, can we make a type for this and use/import it? That should help clarify what this is without having to explain it in every comment.

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.

Makes sense, added in 0b886ec

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

@dkotter
dkotter requested a review from JasonTheAdams August 24, 2026 21:42
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