1536, 'text-embedding-3-large' => 3072]; public function __construct(private EmbeddingTransport $transport, private Ledger $ledger, private int $siteId, private string $apiModel) { if (!isset(self::MODELS[$apiModel])) { throw new \InvalidArgumentException('Unsupported embedding model.'); } } public function model(): string { return 'openai:' . $this->apiModel . ':' . $this->dimension(); } public function dimension(): int { return self::MODELS[$this->apiModel]; } /** The ledger request id of an embedding operation (so a successor can look up what a dead worker dispatched). */ public static function requestId(string $operation): string { return 'emb_' . substr(hash('sha256', $operation), 0, 56); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes public function embed(array $texts): array { throw new ExtractionException('embedding_context', 'An explicit embedding operation is required.', RetrievalFailure::Configuration); } public function embedFor(array $texts, string $operation, array $buckets): array { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if ($operation === '' || count($texts) < 1 || count($texts) > 32) { throw new ExtractionException('embedding_input', 'Invalid embedding batch.', RetrievalFailure::Input); } $bytes = 0; foreach ($texts as $text) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if (!is_string($text) || trim($text) === '' || strlen($text) > 8192 || !mb_check_encoding($text, 'UTF-8')) { throw new ExtractionException('embedding_input', 'Embedding input exceeds the supported text limit.', RetrievalFailure::Input); } $bytes += strlen($text); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if ($bytes > 131072) { throw new ExtractionException('embedding_input', 'Embedding batch is too large.', RetrievalFailure::Input); } $request = self::requestId($operation); try { $reservation = $this->ledger->reserve($this->siteId, $request, $buckets, $bytes + 128 * count($texts)); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes catch (\Aiwend\Usage\BudgetExceededException) { throw new ExtractionException('embedding_budget', 'Embedding budget is unavailable or paused.', RetrievalFailure::Budget); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes catch (\Aiwend\Usage\SpendingHaltedException) { throw new ExtractionException('embedding_budget', 'Embedding budget is unavailable or paused.', RetrievalFailure::Paused); } // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes if ($reservation->duplicate) { throw new ExtractionException('embedding_duplicate', 'This embedding operation was already attempted. Review usage before explicitly retrying.', RetrievalFailure::Duplicate); } $this->ledger->markDispatched($this->siteId, $request); $settled = false; try { $data = $this->transport->request(['model' => $this->apiModel, 'input' => array_values($texts), 'encoding_format' => 'float']); $usage = $data['usage']['total_tokens'] ?? null; if (is_int($usage) && $usage >= 0) { $this->ledger->settle($this->siteId, $request, $usage, null, Reservation::BASIS_REPORTED); $settled = true; } else { throw new EmbeddingResponseException(RetrievalFailure::Uncertain, 'Missing usage.'); } if (($data['model'] ?? '') !== $this->apiModel || !is_array($data['data'] ?? null) || count($data['data']) !== count($texts)) { throw new EmbeddingResponseException(RetrievalFailure::Model, 'Model or batch mismatch.'); } $vectors = []; foreach ($data['data'] as $item) { $index = $item['index'] ?? null; $vector = $item['embedding'] ?? null; if (!is_int($index) || $index < 0 || $index >= count($texts) || isset($vectors[$index]) || !is_array($vector) || !array_is_list($vector) || count($vector) !== $this->dimension()) { throw new EmbeddingResponseException(RetrievalFailure::Model, 'Invalid vector shape.'); } $norm = 0.0; foreach ($vector as $value) { if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value)) { throw new EmbeddingResponseException(RetrievalFailure::Model, 'Invalid coordinate.'); } $norm += $value * $value; } if ($norm <= 0 || !is_finite($norm)) { throw new EmbeddingResponseException(RetrievalFailure::Model, 'Invalid vector norm.'); } $norm = sqrt($norm); $vectors[$index] = array_map(static fn($v): float => $v / $norm, $vector); } ksort($vectors); return array_values($vectors); } catch (\Throwable $error) { if (!$settled) { if ($error instanceof ProviderException && !$error->dispatched) { $this->ledger->settle($this->siteId, $request, 0, null, Reservation::BASIS_NOT_DISPATCHED); } else { $this->ledger->markUncertain($this->siteId, $request, 'embedding-outcome-unknown'); } } // Typed, never message-matched: provider kinds and our own response checks decide the class; anything else after dispatch is an unknown outcome. $failure = match (true) { $error instanceof EmbeddingResponseException => $error->failure, $error instanceof ProviderException => in_array($error->kind, [ProviderException::AUTH, ProviderException::INVALID_REQUEST], true) ? RetrievalFailure::Configuration : RetrievalFailure::Provider, default => RetrievalFailure::Uncertain, }; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new ExtractionException('embedding_failed', 'Embedding failed. Review the connection and usage; no automatic retry was made.', $failure); } } }