*/ public function all(): array { $stored = $this->options->get(self::OPTION, []); $out = []; foreach (is_array($stored) ? $stored : [] as $e) { $clean = is_array($e) ? self::clean($e) : null; if ($clean !== null && isset($e['entered_at']) && is_int($e['entered_at']) && $e['entered_at'] > 0) { $out[] = $clean + ['entered_at' => $e['entered_at']]; } } return $out; } /** * @param array $in provider, model, currency, input, cached_input, output, confirm * @return array{ok:bool, error:?string} error: provider, model, currency, rate, confirm or limit */ public function save(array $in): array { if ((string) ($in['confirm'] ?? '') !== '1') { return ['ok' => false, 'error' => 'confirm']; } $provider = (string) ($in['provider'] ?? ''); if (!in_array($provider, self::PROVIDERS, true)) { return ['ok' => false, 'error' => 'provider']; } $model = (string) ($in['model'] ?? ''); if (preg_match(self::MODEL, $model) !== 1) { return ['ok' => false, 'error' => 'model']; } $currency = (string) ($in['currency'] ?? ''); if (preg_match('/^[A-Z]{3}$/D', $currency) !== 1) { return ['ok' => false, 'error' => 'currency']; } $cachedText = trim((string) ($in['cached_input'] ?? '')); $entry = self::clean([ 'provider' => $provider, 'model' => $model, 'currency' => $currency, 'input' => trim((string) ($in['input'] ?? '')), 'cached_input' => $cachedText === '' ? null : $cachedText, 'output' => trim((string) ($in['output'] ?? '')), ]); if ($entry === null) { return ['ok' => false, 'error' => 'rate']; } $list = array_values(array_filter($this->all(), static fn(array $e): bool => !($e['provider'] === $provider && $e['model'] === $model))); if (count($list) >= self::MAX) { return ['ok' => false, 'error' => 'limit']; } $list[] = $entry + ['entered_at' => $this->clock->now()]; $this->options->set(self::OPTION, $list); return ['ok' => true, 'error' => null]; } public function remove(string $provider, string $model): bool { $all = $this->all(); $kept = array_values(array_filter($all, static fn(array $e): bool => !($e['provider'] === $provider && $e['model'] === $model))); if (count($kept) === count($all)) { return false; } $this->options->set(self::OPTION, $kept); return true; } /** * @param array $e * @return array{provider:string, model:string, currency:string, input:string, cached_input:?string, output:string}|null */ private static function clean(array $e): ?array { $provider = $e['provider'] ?? null; $model = $e['model'] ?? null; $currency = $e['currency'] ?? null; $input = $e['input'] ?? null; $output = $e['output'] ?? null; $cached = $e['cached_input'] ?? null; if (!is_string($provider) || !in_array($provider, self::PROVIDERS, true) || !is_string($model) || preg_match(self::MODEL, $model) !== 1 || !is_string($currency) || preg_match('/^[A-Z]{3}$/D', $currency) !== 1 || !is_string($input) || !is_string($output) || ($cached !== null && !is_string($cached))) { return null; } $in = Decimal::toMicro($input); $out = Decimal::toMicro($output); $ca = $cached === null ? null : Decimal::toMicro($cached); // a price of zero would claim the model is free; a cached read dearer than fresh input is a typing mistake if ($in === null || $out === null || $in === 0 || $out === 0 || ($cached !== null && ($ca === null || $ca === 0 || $ca > $in))) { return null; } return ['provider' => $provider, 'model' => $model, 'currency' => $currency, 'input' => $input, 'cached_input' => $cached, 'output' => $output]; } }