From 6e5ffae261a793b3f448b1594013ff071f32eef3 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 13 Sep 2026 08:40:06 +0100 Subject: [PATCH] Let GeminiModel answer for each model, and move the default to 3.8 Flash --- .github/model-watch.php | 32 +++--- .github/workflows/model-watch.yml | 3 + README.md | 8 +- docs/provider.md | 8 +- src/GeminiModel.php | 161 ++++++++++++++++++++++++++++++ src/GoogleProvider.php | 66 ++++++------ tests/Unit/GeminiModelTest.php | 63 ++++++++++++ tests/Unit/GoogleProviderTest.php | 2 +- 8 files changed, 292 insertions(+), 51 deletions(-) create mode 100644 src/GeminiModel.php create mode 100644 tests/Unit/GeminiModelTest.php diff --git a/.github/model-watch.php b/.github/model-watch.php index 4d7bb66..fc4fbf4 100644 --- a/.github/model-watch.php +++ b/.github/model-watch.php @@ -12,6 +12,8 @@ declare(strict_types=1); +use PapiAI\Google\GeminiModel; + /* * Compares the model IDs this package ships against what the provider currently serves. * @@ -23,6 +25,8 @@ * them on purpose so callers get a deprecation note rather than an undefined-constant fatal. */ +require __DIR__ . '/../vendor/autoload.php'; + $source = file_get_contents(__DIR__ . '/../src/GoogleProvider.php'); if ($source === false) { @@ -33,28 +37,24 @@ /** @var array $shipped */ $shipped = []; -$lines = explode("\n", $source); -// Line by line, not one big regex. A regex that also tries to capture the preceding docblock -// matches across earlier constants and silently drops them, and a checker that quietly skips -// models is worse than no checker: the ones it skipped here were the newest four. -foreach ($lines as $number => $line) { - if (preg_match('/public const (?PMODEL_\w+|IMAGEN_\w+) = \'(?P[^\']+)\';/', $line, $match) !== 1) { - continue; - } - - // Only the line immediately above can carry the marker, which is where php-cs-fixer puts it. - $shipped[$match['name']] = [ - 'value' => $match['value'], - 'deprecated' => str_contains($lines[$number - 1] ?? '', '@deprecated'), +// The enum is the source of truth: every ID we ship is a case, and each case knows whether it +// has been retired. No parsing of docblocks, which is what silently skipped four models before. +foreach (GeminiModel::cases() as $model) { + $shipped[$model->name] = [ + 'value' => $model->value, + 'deprecated' => $model->isDeprecated(), 'isDefault' => false, ]; } // Anything used as a fallback is the dangerous kind: it breaks callers who pass no model at all. -foreach ($shipped as $name => $entry) { - if (preg_match('/\?\?\s*self::' . preg_quote($name, '/') . '\b/', $source) === 1) { - $shipped[$name]['isDefault'] = true; +// The provider spells a default as `?? self::CONSTANT`, and each constant aliases an enum case. +preg_match_all('/public const (?P\w+) = GeminiModel::(?P\w+)->value;/', $source, $aliases, PREG_SET_ORDER); + +foreach ($aliases as $alias) { + if (isset($shipped[$alias['case']]) && preg_match('/\?\?\s*self::' . preg_quote($alias['constant'], '/') . '\b/', $source) === 1) { + $shipped[$alias['case']]['isDefault'] = true; } } diff --git a/.github/workflows/model-watch.yml b/.github/workflows/model-watch.yml index f3ac612..994417c 100644 --- a/.github/workflows/model-watch.yml +++ b/.github/workflows/model-watch.yml @@ -31,6 +31,9 @@ jobs: php-version: '8.3' coverage: none + - name: Install dependencies + run: composer install --no-dev --prefer-dist --no-progress --no-interaction + - name: Check models id: check run: php .github/model-watch.php diff --git a/README.md b/README.md index e375cfc..5b8189d 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider( apiKey: $_ENV['GOOGLE_API_KEY'], - defaultModel: GoogleProvider::MODEL_3_6_FLASH, + defaultModel: GoogleProvider::MODEL_3_8_FLASH, ); $agent = new Agent( provider: $provider, - model: GoogleProvider::MODEL_3_6_FLASH, + model: GoogleProvider::MODEL_3_8_FLASH, instructions: 'You are a helpful assistant.', ); @@ -36,7 +36,9 @@ echo $response->text; ### Gemini (text and chat) ```php -GoogleProvider::MODEL_3_6_FLASH // 'gemini-3.6-flash' (default) +GoogleProvider::MODEL_3_8_FLASH // 'gemini-3.8-flash' (default) +GoogleProvider::MODEL_3_7_FLASH // 'gemini-3.7-flash' +GoogleProvider::MODEL_3_6_FLASH // 'gemini-3.6-flash' GoogleProvider::MODEL_3_5_FLASH // 'gemini-3.5-flash' GoogleProvider::MODEL_3_5_FLASH_LITE // 'gemini-3.5-flash-lite' GoogleProvider::MODEL_3_1_PRO // 'gemini-3.1-pro-preview' diff --git a/docs/provider.md b/docs/provider.md index 75493de..b1f863b 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -16,12 +16,12 @@ use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider( apiKey: $_ENV['GOOGLE_API_KEY'], - defaultModel: GoogleProvider::MODEL_3_6_FLASH, + defaultModel: GoogleProvider::MODEL_3_8_FLASH, ); $agent = new Agent( provider: $provider, - model: GoogleProvider::MODEL_3_6_FLASH, + model: GoogleProvider::MODEL_3_8_FLASH, instructions: 'You are a helpful assistant.', ); @@ -34,7 +34,9 @@ echo $response->text; ### Chat Models ```php -GoogleProvider::MODEL_3_6_FLASH // gemini-3.6-flash (default) +GoogleProvider::MODEL_3_8_FLASH // gemini-3.8-flash (default) +GoogleProvider::MODEL_3_7_FLASH // gemini-3.7-flash +GoogleProvider::MODEL_3_6_FLASH // gemini-3.6-flash GoogleProvider::MODEL_3_5_FLASH // gemini-3.5-flash GoogleProvider::MODEL_3_5_FLASH_LITE // gemini-3.5-flash-lite GoogleProvider::MODEL_3_1_PRO // gemini-3.1-pro-preview diff --git a/src/GeminiModel.php b/src/GeminiModel.php new file mode 100644 index 0000000..959cdea --- /dev/null +++ b/src/GeminiModel.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PapiAI\Google; + +use PapiAI\Core\Effort; + +/** + * Every Google model this package knows, and what each one accepts. + * + * The model, not the provider, decides which thinking knob it takes, whether thinking can be + * switched off and which effort levels exist. Putting that here keeps the provider free of + * string-sniffing, and lets the model watch enumerate what we ship instead of parsing source. + * + * An ID we have not heard of is not an error: `tryFrom()` returns null and the provider reads the + * generation from the name, assuming newer rather than older. + * + * Retirement dates are Google's published shutdown dates, ISO formatted. + * + * @see https://ai.google.dev/gemini-api/docs/models + */ +enum GeminiModel: string +{ + case Flash38 = 'gemini-3.8-flash'; + case Flash37 = 'gemini-3.7-flash'; + case Flash36 = 'gemini-3.6-flash'; + case Flash35 = 'gemini-3.5-flash'; + case Flash35Lite = 'gemini-3.5-flash-lite'; + case Pro31 = 'gemini-3.1-pro-preview'; + case Flash31Lite = 'gemini-3.1-flash-lite'; + case Flash3 = 'gemini-3-flash-preview'; + case Pro25 = 'gemini-2.5-pro'; + case Flash25 = 'gemini-2.5-flash'; + case Flash25Lite = 'gemini-2.5-flash-lite'; + case Flash20 = 'gemini-2.0-flash'; + case Flash20Lite = 'gemini-2.0-flash-lite'; + /** @deprecated Shut down 9 March 2026; the alias now redirects to gemini-3.1-pro-preview. */ + case Pro3 = 'gemini-3-pro-preview'; + /** @deprecated Retired; no longer published by Google. */ + case Pro15 = 'gemini-1.5-pro'; + /** @deprecated Retired; no longer published by Google. */ + case Flash15 = 'gemini-1.5-flash'; + + case Flash31Image = 'gemini-3.1-flash-image'; + case Flash31LiteImage = 'gemini-3.1-flash-lite-image'; + case Pro3Image = 'gemini-3-pro-image'; + case Flash25Image = 'gemini-2.5-flash-image'; + /** @deprecated Imagen shut down 17 August 2026. Use Flash31Image. */ + case Imagen4 = 'imagen-4.0-generate-001'; + /** @deprecated Imagen shut down 17 August 2026. Use Flash31Image. */ + case Imagen4Ultra = 'imagen-4.0-ultra-generate-001'; + /** @deprecated Imagen shut down 17 August 2026. Use Flash31Image. */ + case Imagen4Fast = 'imagen-4.0-fast-generate-001'; + /** @deprecated Imagen 3 is already shut down. Use Flash31Image. */ + case ImagenEdit = 'imagen-3.0-capability-001'; + + case Veo31 = 'veo-3.1-generate-preview'; + case Veo31Lite = 'veo-3.1-lite-generate-preview'; + /** @deprecated Shut down 30 June 2026; requests fail. Use Veo31. */ + case Veo3 = 'veo-3.0-generate-001'; + /** @deprecated Shut down 30 June 2026; requests fail. Use Veo31. */ + case Veo2 = 'veo-2.0-generate-001'; + + /** + * Whether this model takes a thinking level (Gemini 3 and later) rather than a token budget. + * + * Google warns that the budget knob behaves unpredictably on Gemini 3 Pro, so the level knob + * is always used from 3 on. + */ + public function takesThinkingLevel(): bool + { + return match ($this) { + self::Flash38, self::Flash37, self::Flash36, self::Flash35, self::Flash35Lite, + self::Pro31, self::Flash31Lite, self::Flash3, self::Pro3 => true, + default => false, + }; + } + + /** + * The thinking levels this model accepts, in the neutral vocabulary. + * + * Only meaningful for models that take a level. No Gemini 3 model can switch thinking off, + * and Pro does not accept MINIMAL at all, so its floor is LOW. Budget models return an empty + * list: their knob is continuous. + * + * @return list + */ + public function effortLevels(): array + { + if (!$this->takesThinkingLevel()) { + return []; + } + + return match ($this) { + self::Pro31, self::Pro3 => [Effort::Low, Effort::Medium, Effort::High], + default => [Effort::Minimal, Effort::Low, Effort::Medium, Effort::High], + }; + } + + /** + * Whether a zero budget genuinely disables thinking. Only the pre-3 Flash families can; + * Pro has a floor it will not go below. + */ + public function canDisableThinking(): bool + { + return match ($this) { + self::Flash25, self::Flash25Lite, self::Flash20, self::Flash20Lite, self::Flash15 => true, + default => false, + }; + } + + /** + * Whether Google has retired this model. + */ + public function isDeprecated(): bool + { + return match ($this) { + self::Pro3, self::Pro15, self::Flash15, + self::Imagen4, self::Imagen4Ultra, self::Imagen4Fast, self::ImagenEdit, + self::Veo3, self::Veo2 => true, + default => false, + }; + } + + /** + * The published shutdown date, ISO formatted, where Google gave one. + */ + public function retiredOn(): ?string + { + return match ($this) { + self::Pro3 => '2026-03-09', + self::Imagen4, self::Imagen4Ultra, self::Imagen4Fast => '2026-08-17', + self::Veo3, self::Veo2 => '2026-06-30', + default => null, + }; + } + + /** + * What to use instead, for the retired models that have a successor here. + */ + public function replacement(): ?self + { + return match ($this) { + self::Pro3 => self::Pro31, + self::Imagen4, self::Imagen4Ultra, self::Imagen4Fast, self::ImagenEdit => self::Flash31Image, + self::Veo3, self::Veo2 => self::Veo31, + default => null, + }; + } +} diff --git a/src/GoogleProvider.php b/src/GoogleProvider.php index c8dd329..8f9de2a 100644 --- a/src/GoogleProvider.php +++ b/src/GoogleProvider.php @@ -85,46 +85,49 @@ class GoogleProvider implements ProviderInterface, ImageProviderInterface, Embed private const MAX_THINKING_BUDGET = 32768; // Gemini model aliases - public const MODEL_3_6_FLASH = 'gemini-3.6-flash'; - public const MODEL_3_5_FLASH = 'gemini-3.5-flash'; - public const MODEL_3_5_FLASH_LITE = 'gemini-3.5-flash-lite'; - public const MODEL_3_1_PRO = 'gemini-3.1-pro-preview'; + public const MODEL_3_8_FLASH = GeminiModel::Flash38->value; + public const MODEL_3_7_FLASH = GeminiModel::Flash37->value; + public const MODEL_3_6_FLASH = GeminiModel::Flash36->value; + public const MODEL_3_5_FLASH = GeminiModel::Flash35->value; + public const MODEL_3_5_FLASH_LITE = GeminiModel::Flash35Lite->value; + public const MODEL_3_1_PRO = GeminiModel::Pro31->value; /** @deprecated Shut down 9 March 2026; the alias now redirects to gemini-3.1-pro-preview. */ - public const MODEL_3_0_PRO = 'gemini-3-pro-preview'; - public const MODEL_3_FLASH = 'gemini-3-flash-preview'; - public const MODEL_3_PRO_IMAGE = 'gemini-3-pro-image'; - public const MODEL_2_5_PRO = 'gemini-2.5-pro'; - public const MODEL_2_5_FLASH = 'gemini-2.5-flash'; - public const MODEL_2_5_FLASH_LITE = 'gemini-2.5-flash-lite'; - public const MODEL_2_0_FLASH = 'gemini-2.0-flash'; - public const MODEL_2_0_FLASH_LITE = 'gemini-2.0-flash-lite'; + public const MODEL_3_0_PRO = GeminiModel::Pro3->value; + public const MODEL_3_1_FLASH_LITE = GeminiModel::Flash31Lite->value; + public const MODEL_3_FLASH = GeminiModel::Flash3->value; + public const MODEL_3_PRO_IMAGE = GeminiModel::Pro3Image->value; + public const MODEL_2_5_PRO = GeminiModel::Pro25->value; + public const MODEL_2_5_FLASH = GeminiModel::Flash25->value; + public const MODEL_2_5_FLASH_LITE = GeminiModel::Flash25Lite->value; + public const MODEL_2_0_FLASH = GeminiModel::Flash20->value; + public const MODEL_2_0_FLASH_LITE = GeminiModel::Flash20Lite->value; /** @deprecated Retired; no longer published by Google. */ - public const MODEL_1_5_PRO = 'gemini-1.5-pro'; + public const MODEL_1_5_PRO = GeminiModel::Pro15->value; /** @deprecated Retired; no longer published by Google. */ - public const MODEL_1_5_FLASH = 'gemini-1.5-flash'; + public const MODEL_1_5_FLASH = GeminiModel::Flash15->value; // Image generation and editing, all reached through generateContent. Imagen is retired as a // product line and its separate predict endpoint went with it; these replace both. - public const MODEL_3_1_FLASH_IMAGE = 'gemini-3.1-flash-image'; - public const MODEL_3_1_FLASH_LITE_IMAGE = 'gemini-3.1-flash-lite-image'; - public const MODEL_2_5_FLASH_IMAGE = 'gemini-2.5-flash-image'; + public const MODEL_3_1_FLASH_IMAGE = GeminiModel::Flash31Image->value; + public const MODEL_3_1_FLASH_LITE_IMAGE = GeminiModel::Flash31LiteImage->value; + public const MODEL_2_5_FLASH_IMAGE = GeminiModel::Flash25Image->value; /** @deprecated Imagen shuts down 17 August 2026. Use MODEL_3_1_FLASH_IMAGE. */ - public const IMAGEN_4 = 'imagen-4.0-generate-001'; + public const IMAGEN_4 = GeminiModel::Imagen4->value; /** @deprecated Imagen shuts down 17 August 2026. Use MODEL_3_1_FLASH_IMAGE. */ - public const IMAGEN_4_ULTRA = 'imagen-4.0-ultra-generate-001'; + public const IMAGEN_4_ULTRA = GeminiModel::Imagen4Ultra->value; /** @deprecated Imagen shuts down 17 August 2026. Use MODEL_3_1_FLASH_IMAGE. */ - public const IMAGEN_4_FAST = 'imagen-4.0-fast-generate-001'; + public const IMAGEN_4_FAST = GeminiModel::Imagen4Fast->value; /** @deprecated Imagen 3 is already shut down. Use MODEL_3_1_FLASH_IMAGE. */ - public const IMAGEN_EDIT = 'imagen-3.0-capability-001'; + public const IMAGEN_EDIT = GeminiModel::ImagenEdit->value; // Veo model aliases for video generation - public const MODEL_VEO_3_1 = 'veo-3.1-generate-preview'; - public const MODEL_VEO_3_1_LITE = 'veo-3.1-lite-generate-preview'; + public const MODEL_VEO_3_1 = GeminiModel::Veo31->value; + public const MODEL_VEO_3_1_LITE = GeminiModel::Veo31Lite->value; /** @deprecated Shut down 30 June 2026; requests fail. Use MODEL_VEO_3_1. */ - public const MODEL_VEO_3 = 'veo-3.0-generate-001'; + public const MODEL_VEO_3 = GeminiModel::Veo3->value; /** @deprecated Shut down 30 June 2026; requests fail. Use MODEL_VEO_3_1. */ - public const MODEL_VEO_2 = 'veo-2.0-generate-001'; + public const MODEL_VEO_2 = GeminiModel::Veo2->value; /** @var array tool call ID to thought signature mapping for multi-turn tool use */ private array $thoughtSignatures = []; @@ -139,7 +142,7 @@ class GoogleProvider implements ProviderInterface, ImageProviderInterface, Embed */ public function __construct( private readonly string $apiKey, - private readonly string $defaultModel = self::MODEL_3_6_FLASH, + private readonly string $defaultModel = self::MODEL_3_8_FLASH, private readonly int $defaultMaxTokens = 8192, private readonly ?Effort $defaultEffort = null, ) { @@ -1133,6 +1136,12 @@ private function thinkingConfigFor(Effort $effort, string $model, int $maxTokens */ private function levelsFor(string $model): array { + $known = GeminiModel::tryFrom($model)?->effortLevels() ?? []; + + if ($known !== []) { + return $known; + } + if (stripos($model, 'pro') !== false) { return [Effort::Low, Effort::Medium, Effort::High]; } @@ -1149,7 +1158,7 @@ private function levelsFor(string $model): array */ private function budgetFor(Effort $effort, string $model, int $maxTokens): int { - $canDisable = stripos($model, 'flash') !== false; + $canDisable = GeminiModel::tryFrom($model)?->canDisableThinking() ?? stripos($model, 'flash') !== false; if (!$effort->thinks() && $canDisable) { return 0; @@ -1167,7 +1176,8 @@ private function budgetFor(Effort $effort, string $model, int $maxTokens): int */ private function takesThinkingLevel(string $model): bool { - return preg_match('/gemini-([3-9]|\d{2,})/i', $model) === 1; + return GeminiModel::tryFrom($model)?->takesThinkingLevel() + ?? preg_match('/gemini-([3-9]|\d{2,})/i', $model) === 1; } /** diff --git a/tests/Unit/GeminiModelTest.php b/tests/Unit/GeminiModelTest.php new file mode 100644 index 0000000..f64f08e --- /dev/null +++ b/tests/Unit/GeminiModelTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use PapiAI\Core\Effort; +use PapiAI\Google\GeminiModel; +use PapiAI\Google\GoogleProvider; + +describe('GeminiModel', function () { + it('is the source of truth the old constants alias', function () { + expect(GoogleProvider::MODEL_3_6_FLASH)->toBe(GeminiModel::Flash36->value); + expect(GoogleProvider::MODEL_3_8_FLASH)->toBe('gemini-3.8-flash'); + expect(GoogleProvider::IMAGEN_4)->toBe(GeminiModel::Imagen4->value); + expect(GoogleProvider::MODEL_VEO_3_1)->toBe(GeminiModel::Veo31->value); + }); + + it('returns null for an ID it has not heard of', function () { + expect(GeminiModel::tryFrom('gemini-4-flash'))->toBeNull(); + }); + + it('ships unique IDs', function () { + $ids = array_map(fn (GeminiModel $m) => $m->value, GeminiModel::cases()); + + expect($ids)->toBe(array_unique($ids)); + }); + + it('takes a level from Gemini 3 on and a budget before', function () { + expect(GeminiModel::Flash38->takesThinkingLevel())->toBeTrue(); + expect(GeminiModel::Pro31->takesThinkingLevel())->toBeTrue(); + expect(GeminiModel::Flash25->takesThinkingLevel())->toBeFalse(); + expect(GeminiModel::Pro25->takesThinkingLevel())->toBeFalse(); + }); + + it('keeps Pro off MINIMAL and gives Flash all four levels', function () { + expect(GeminiModel::Pro31->effortLevels())->toBe([Effort::Low, Effort::Medium, Effort::High]); + expect(GeminiModel::Flash38->effortLevels())->toBe([Effort::Minimal, Effort::Low, Effort::Medium, Effort::High]); + expect(GeminiModel::Flash25->effortLevels())->toBe([]); + }); + + it('lets only the pre-3 Flash families disable thinking', function () { + expect(GeminiModel::Flash25->canDisableThinking())->toBeTrue(); + expect(GeminiModel::Pro25->canDisableThinking())->toBeFalse(); + expect(GeminiModel::Flash38->canDisableThinking())->toBeFalse(); + }); + + it('knows the dead ones, their dates and their successors', function () { + expect(GeminiModel::Imagen4->isDeprecated())->toBeTrue(); + expect(GeminiModel::Imagen4->retiredOn())->toBe('2026-08-17'); + expect(GeminiModel::Imagen4->replacement())->toBe(GeminiModel::Flash31Image); + expect(GeminiModel::Veo2->replacement())->toBe(GeminiModel::Veo31); + expect(GeminiModel::Flash38->isDeprecated())->toBeFalse(); + }); +}); diff --git a/tests/Unit/GoogleProviderTest.php b/tests/Unit/GoogleProviderTest.php index 358468f..8ba178b 100644 --- a/tests/Unit/GoogleProviderTest.php +++ b/tests/Unit/GoogleProviderTest.php @@ -175,7 +175,7 @@ public function callThrowForStatusCode(int $httpCode, ?array $data): never $this->provider->chat([Message::user('Hello')]); expect($this->provider->lastPayload['generationConfig']['maxOutputTokens'])->toBe(8192); - expect($this->provider->lastUrl)->toContain('gemini-3.6-flash'); + expect($this->provider->lastUrl)->toContain('gemini-3.8-flash'); }); it('overrides model and options from parameters', function () {