From 482f6ee319a1aefd83d5c158ca495a00da6d6b08 Mon Sep 17 00:00:00 2001 From: Yifeng Lu Date: Tue, 22 Sep 2026 13:34:57 -0700 Subject: [PATCH] Add Claude Opus 5.5 to Langfun's LLM registry (Anthropic + Vertex AI) WHY Claude Opus 5.5 is Anthropic's newest Opus model, generally available since 2026-09-22 on both the direct Anthropic Messages API and Vertex AI. Langfun exposes no way to select it, so users are stuck on Opus 5 or have to hand-roll a model class of their own. WHAT * `langfun/core/llms/anthropic.py`: a new `SUPPORTED_MODELS` row for the model id `claude-opus-5-5`, and a new `Claude55Opus` class. * `langfun/core/llms/vertexai.py`: a new `VertexAIClaude55Opus` class (`location='global'`) plus the single registry entry that maps `claude-opus-5-5` to it. * `langfun/core/llms/__init__.py`: export `Claude55Opus` and `VertexAIClaude55Opus`. * Unit tests for both modules, including tests that pin the package-level exports. Additive: no existing model row, class, export or test is modified. The `model` enum is derived from `SUPPORTED_MODELS`, so the new row makes the id selectable automatically. HOW * The registration mirrors the existing Opus 4.8 pattern end to end. The new row is placed after Opus 5 to keep `SUPPORTED_MODELS` ordered oldest-first. * Context window (1M input / 128K output), pricing and capabilities are taken from the vendor's published model spec. Pricing is 0.2 / 4.0 / 20.0 USD per 1M cached-input / input / output tokens, which is lower than every earlier Opus row -- the 4.5-5 rows carry 0.5 / 5.0 / 25.0 and the Claude 3/4 Opus rows 1.5 / 15 / 75. The numbers are deliberately not cloned from a sibling row, and a test pins them so that a future copy-paste regression fails loudly. * The explicit `claude-opus-5-5` -> `VertexAIClaude55Opus` registration is required rather than redundant: auto-registration only covers rows whose provider is 'VertexAI', and the direct row declares provider='Anthropic', so without it `lf.LanguageModel.get('claude-opus-5-5')` silently resolves to the direct-API class. A test pins the resolution. * No `claude-opus-5-5@latest` alias row is added. Vertex AI publishes Claude models under explicit versions, and Langfun already strips an `@latest` suffix before it constructs the request URL, so a separate alias row would be redundant. This mirrors the existing `VertexAIClaude5Opus` entry, which is likewise registered without an alias. The consequence, stated plainly, is that `VertexAIClaude55Opus.model_info` resolves to the Anthropic-direct model row, exactly as `VertexAIClaude5Opus` does today. * No request-shaping gate changed. Opus 4.7 and later reject non-default `temperature`, `top_p` and `top_k`, and Opus 5.5 inherits adaptive thinking, `display='summarized'` and that existing parameter strip from the `'claude-opus-5' in model` substring checks, which already match `claude-opus-5-5`; an explicit clause for the new id would be dead code. Tests pin the inherited behavior, and the one stale comment that enumerated "4.7, 4.8 and 5" now names 5.5 and records why no clause was added. * `rate_limits` on the direct row are carried over from the sibling Opus row and marked UNVERIFIED in-code: no published document grounds Opus 5.5 quota yet. PiperOrigin-RevId: 986200894 --- langfun/core/llms/__init__.py | 2 + langfun/core/llms/anthropic.py | 43 ++++++- langfun/core/llms/anthropic_test.py | 193 +++++++++++++++++++++++++++- langfun/core/llms/vertexai.py | 8 ++ langfun/core/llms/vertexai_test.py | 29 +++++ 5 files changed, 273 insertions(+), 2 deletions(-) diff --git a/langfun/core/llms/__init__.py b/langfun/core/llms/__init__.py index a301dc02..1ace937e 100644 --- a/langfun/core/llms/__init__.py +++ b/langfun/core/llms/__init__.py @@ -175,6 +175,7 @@ # Anthropic models. +from langfun.core.llms.anthropic import Claude55Opus from langfun.core.llms.anthropic import Claude5Opus from langfun.core.llms.anthropic import Claude48Opus from langfun.core.llms.anthropic import Claude47Opus @@ -201,6 +202,7 @@ from langfun.core.llms.anthropic import Claude3Haiku_20240307 from langfun.core.llms.vertexai import VertexAIAnthropic +from langfun.core.llms.vertexai import VertexAIClaude55Opus from langfun.core.llms.vertexai import VertexAIClaude5Opus from langfun.core.llms.vertexai import VertexAIClaude48Opus from langfun.core.llms.vertexai import VertexAIClaude47Opus diff --git a/langfun/core/llms/anthropic.py b/langfun/core/llms/anthropic.py index 81ccd9e5..21ca28b3 100644 --- a/langfun/core/llms/anthropic.py +++ b/langfun/core/llms/anthropic.py @@ -169,6 +169,37 @@ def max_tokens_per_minute(self) -> int: # pyrefly: ignore[bad-override] max_output_tokens_per_minute=400_000, ), ), + AnthropicModelInfo( + model_id='claude-opus-5-5', + provider='Anthropic', + in_service=True, + description='Claude Opus 5.5 model.', + release_date=datetime.datetime(2026, 9, 22), + input_modalities=( + AnthropicModelInfo.INPUT_IMAGE_TYPES + + AnthropicModelInfo.INPUT_DOC_TYPES + ), + context_length=lf.ModelInfo.ContextLength( + max_input_tokens=1_000_000, + max_output_tokens=128_000, + ), + # Pricing per 1M tokens as of 2026-09-22, from the vendor's published + # pricing page. Opus 5.5 is cheaper than every earlier Opus entry, so + # these numbers must not be copied from a sibling row. + pricing=lf.ModelInfo.Pricing( + cost_per_1m_cached_input_tokens=0.2, + cost_per_1m_input_tokens=4.0, + cost_per_1m_output_tokens=20.0, + ), + # UNVERIFIED: no public/internal doc grounds Opus 5.5 quota; these + # rate_limits are copied from the Opus 5 entry as a best-effort + # placeholder. Update once official Opus 5.5 limits are published. + rate_limits=AnthropicModelInfo.RateLimits( + max_requests_per_minute=2000, + max_input_tokens_per_minute=1_000_000, + max_output_tokens_per_minute=400_000, + ), + ), AnthropicModelInfo( model_id='claude-haiku-4-5-20251001', provider='Anthropic', @@ -1275,7 +1306,11 @@ def _request_args(self, options: lf.LMSamplingOptions) -> dict[str, Any]: 'with no `max_thinking_tokens`), and effort only applies to thinking' ) - # Claude Opus 4.7, 4.8 and 5 do not support temperature, top_p, or top_k. + # Claude Opus 4.7, 4.8, 5 and 5.5 do not support temperature, top_p, or + # top_k. `claude-opus-5-5` needs no clause of its own: it is already + # matched by the `'claude-opus-5' in ...` substring test below, so adding + # one would be dead code. `anthropic_test.Claude55OpusTest` pins this + # behavior, so tightening these tests to exact matches fails loudly. if self.model is not None and ( 'claude-opus-4-7' in self.model or 'claude-opus-4-8' in self.model @@ -1508,6 +1543,12 @@ class Claude46(Anthropic): # pylint: disable=invalid-name +class Claude55Opus(Anthropic): + """Claude Opus 5.5 model.""" + + model = 'claude-opus-5-5' + + class Claude5Opus(Anthropic): """Claude Opus 5 model.""" diff --git a/langfun/core/llms/anthropic_test.py b/langfun/core/llms/anthropic_test.py index c6a41ea4..d716d0ab 100644 --- a/langfun/core/llms/anthropic_test.py +++ b/langfun/core/llms/anthropic_test.py @@ -25,7 +25,7 @@ import langfun.core as lf from langfun.core import modalities as lf_modalities from langfun.core.llms import anthropic -from langfun.core.llms import vertexai # pylint: disable=unused-import +from langfun.core.llms import vertexai import pyglove as pg import requests @@ -1116,6 +1116,197 @@ def test_opus48_call_e2e(self): self.assertIn('top_p=None', response.text) +class Claude55OpusTest(unittest.TestCase): + """Tests for Claude Opus 5.5 model support.""" + + def test_opus55_basics(self): + """Test Claude Opus 5.5 basic instantiation.""" + lm = anthropic.Claude55Opus(api_key='fake') + self.assertEqual(lm.model_id, 'claude-opus-5-5') + self.assertTrue(lm._use_adaptive_thinking) + + def test_opus55_model_info(self): + """Test Claude Opus 5.5 model info is registered.""" + opus_entries = [ + info + for info in anthropic.SUPPORTED_MODELS + if info.model_id == 'claude-opus-5-5' + ] + self.assertEqual(len(opus_entries), 1) + entry = opus_entries[0] + self.assertEqual(entry.provider, 'Anthropic') + self.assertTrue(entry.in_service) + self.assertEqual(entry.context_length.max_input_tokens, 1_000_000) + self.assertEqual(entry.context_length.max_output_tokens, 128_000) + # Opus 5.5 is cheaper than every earlier Opus entry: the 4.5-5 rows carry + # 0.5 / 5.0 / 25.0, the Claude 3/4 Opus rows 1.5 / 15 / 75. Pinning the + # vendor-published numbers here makes a sibling-row copy-paste fail loudly. + self.assertEqual(entry.pricing.cost_per_1m_cached_input_tokens, 0.2) + self.assertEqual(entry.pricing.cost_per_1m_input_tokens, 4.0) + self.assertEqual(entry.pricing.cost_per_1m_output_tokens, 20.0) + + def test_opus55_has_no_vertexai_alias_row(self): + """Opus 5.5 is registered bare-only, mirroring its predecessor Opus 5. + + `claude-opus-5-5@latest` is deliberately NOT registered. Vertex AI publishes + this model under a single version, `claude-opus-5-5@default`, and exposes no + `latest` version for it. An `@latest` row would therefore ship an + unverifiable model id plus unverified rate limits, so Opus 5.5 mirrors + `claude-opus-5`, which is also bare-only. + """ + self.assertEqual( + [ + info + for info in anthropic.SUPPORTED_MODELS + if info.model_id == 'claude-opus-5-5@latest' + ], + [], + ) + # With no VertexAI alias row, `VertexAIAnthropic.model_info` falls back to + # the Anthropic-direct row -- exactly the behaviour of `VertexAIClaude5Opus` + # today. The endpoint is unaffected because it is built from `model`. + model_info = vertexai.VertexAIClaude55Opus(project='test').model_info + self.assertEqual(model_info.model_id, 'claude-opus-5-5') + self.assertEqual(model_info.provider, 'Anthropic') + + def test_opus55_exported_from_llms_package(self): + """Pins the Opus 5.5 exports on the `langfun.core.llms` package. + + `Claude55Opus` and `VertexAIClaude55Opus` must stay importable directly from + `langfun.core.llms`, not only from the modules that define them. Dropping + either export line is a breaking change for callers, so it fails here. + """ + from langfun.core import llms as lf_llms # pylint: disable=g-import-not-at-top + + self.assertIs(lf_llms.Claude55Opus, anthropic.Claude55Opus) + self.assertIs(lf_llms.VertexAIClaude55Opus, vertexai.VertexAIClaude55Opus) + + def test_thinking_param_true_adaptive_opus_5_5(self): + """Claude 5.5 + thinking=True -> adaptive thinking with summarized display.""" + lm = anthropic.Claude55Opus(api_key='fake', thinking=True) + args = lm._request_args( + lf.LMSamplingOptions(max_tokens=1000, temperature=0.5) + ) + self.assertEqual( + args['thinking'], {'type': 'adaptive', 'display': 'summarized'} + ) + self.assertEqual(args['max_tokens'], 1000) + self.assertNotIn('temperature', args) + + def test_opus55_no_thinking_removes_sampling_params(self): + """Opus 5.5 strips temperature/top_k/top_p even with thinking=False.""" + lm = anthropic.Claude55Opus(api_key='fake', thinking=False) + args = lm._request_args( + lf.LMSamplingOptions( + max_tokens=1000, temperature=0.5, top_k=40, top_p=0.9 + ) + ) + self.assertNotIn('thinking', args) + self.assertNotIn('temperature', args) + self.assertNotIn('top_k', args) + self.assertNotIn('top_p', args) + + def test_opus55_default_strips_sampling_params(self): + """Opus 5.5 strips temperature/top_k/top_p even without thinking.""" + lm = anthropic.Claude55Opus(api_key='fake') + args = lm._request_args( + lf.LMSamplingOptions( + max_tokens=1000, temperature=0.7, top_k=40, top_p=0.9 + ) + ) + self.assertNotIn('thinking', args) + self.assertNotIn('temperature', args) + self.assertNotIn('top_k', args) + self.assertNotIn('top_p', args) + + def test_opus55_effort_max(self): + """Test Opus 5.5 with effort='max'.""" + lm = anthropic.Claude55Opus(api_key='fake', effort='max', thinking=True) + args = lm._request_args(lf.LMSamplingOptions(max_tokens=1024)) + self.assertEqual(args['output_config'], {'effort': 'max'}) + + def test_opus55_effort_medium(self): + """Test Opus 5.5 with effort='medium'.""" + lm = anthropic.Claude55Opus(api_key='fake', effort='medium', thinking=True) + args = lm._request_args(lf.LMSamplingOptions(max_tokens=1024)) + self.assertEqual(args['output_config'], {'effort': 'medium'}) + + def test_opus55_reasoning_effort_overrides_model_effort(self): + """reasoning_effort in sampling options overrides model-level effort.""" + lm = anthropic.Claude55Opus(api_key='fake', effort='high', thinking=True) + args = lm._request_args( + lf.LMSamplingOptions(max_tokens=1024, reasoning_effort='low') + ) + self.assertEqual(args['output_config'], {'effort': 'low'}) + + def test_opus55_no_effort(self): + """Test Opus 5.5 with effort=None.""" + lm = anthropic.Claude55Opus(api_key='fake', effort=None) + args = lm._request_args( + lf.LMSamplingOptions(max_tokens=1024, max_thinking_tokens=1024) + ) + self.assertNotIn('output_config', args) + + def test_opus55_thinking_options_adaptive(self): + """Claude 5.5 with thinking options uses adaptive mode.""" + lm = anthropic.Claude55Opus(api_key='fake') + args = lm._request_args( + lf.LMSamplingOptions( + max_thinking_tokens=1024, max_tokens=1000, temperature=0.5 + ) + ) + self.assertEqual( + args['thinking'], {'type': 'adaptive', 'display': 'summarized'} + ) + self.assertEqual(args['output_config'], {'effort': 'high'}) + self.assertEqual(args['max_tokens'], 1000) + self.assertNotIn('temperature', args) + + def test_model_uri_instantiation_opus_5_5(self): + """Test LLM instantiation from model URI for Claude Opus 5.5.""" + model = lf.LanguageModel.get('claude-opus-5-5?api_key=test_key') + self.assertIsInstance(model, anthropic.Anthropic) + self.assertTrue(model._use_adaptive_thinking) + self.assertEqual(model.effort, 'high') + + def test_model_uri_instantiation_opus_5_5_with_thinking(self): + """Test Opus 5.5 model URI with thinking=true.""" + model = lf.LanguageModel.get( + 'claude-opus-5-5?api_key=test_key&thinking=true' + ) + self.assertTrue(model.thinking) + self.assertTrue(model._use_adaptive_thinking) + args = model._request_args(lf.LMSamplingOptions(max_tokens=1024)) + self.assertEqual( + args['thinking'], + {'type': 'adaptive', 'display': 'summarized'}, + ) + self.assertEqual(args['output_config'], {'effort': 'high'}) + + def test_model_uri_instantiation_opus_5_5_no_thinking(self): + """Test Opus 5.5 model URI with thinking=false.""" + model = lf.LanguageModel.get( + 'claude-opus-5-5?api_key=test_key&thinking=false' + ) + self.assertFalse(model.thinking) + args = model._request_args(lf.LMSamplingOptions(max_tokens=1024)) + self.assertNotIn('thinking', args) + # Opus 5.5 still strips temperature/top_k/top_p + self.assertNotIn('temperature', args) + + def test_opus55_call_e2e(self): + """End-to-end call test for Claude Opus 5.5.""" + with mock.patch('requests.Session.post') as mock_request: + mock_request.side_effect = mock_requests_post + lm = anthropic.Claude55Opus(api_key='fake_key', thinking=False) + response = lm('hello') + self.assertIn('hello', response.text) + # Verify sampling params are stripped (temperature/top_k/top_p = None) + self.assertIn('temperature=None', response.text) + self.assertIn('top_k=None', response.text) + self.assertIn('top_p=None', response.text) + + class AnthropicCachingTest(unittest.TestCase): def test_helper_stamps_system_string_to_block_list(self): diff --git a/langfun/core/llms/vertexai.py b/langfun/core/llms/vertexai.py index c0693464..f7507d45 100644 --- a/langfun/core/llms/vertexai.py +++ b/langfun/core/llms/vertexai.py @@ -525,6 +525,13 @@ def request( # pylint: disable=invalid-name +class VertexAIClaude55Opus(VertexAIAnthropic): + """Anthropic's Claude Opus 5.5 model on VertexAI.""" + + model = 'claude-opus-5-5' + location = 'global' + + class VertexAIClaude5Opus(VertexAIAnthropic): """Anthropic's Claude Opus 5 model on VertexAI.""" @@ -871,6 +878,7 @@ def _register_vertexai_models(): lf.LanguageModel.register('claude-opus-4-8', VertexAIClaude48Opus) lf.LanguageModel.register('claude-opus-4-8@latest', anthropic.Anthropic) lf.LanguageModel.register('claude-opus-5', VertexAIClaude5Opus) + lf.LanguageModel.register('claude-opus-5-5', VertexAIClaude55Opus) for m in LLAMA_MODELS: lf.LanguageModel.register(m.model_id, VertexAILlama) diff --git a/langfun/core/llms/vertexai_test.py b/langfun/core/llms/vertexai_test.py index 644c04b6..a2bed6c8 100644 --- a/langfun/core/llms/vertexai_test.py +++ b/langfun/core/llms/vertexai_test.py @@ -284,6 +284,35 @@ def test_lm_get_at_latest_resolves_anthropic(self): anthropic.Anthropic, ) + def test_lm_get_opus_5_5_resolves_vertexai(self): + """Bare `claude-opus-5-5` resolves to VertexAI; no `@latest` id exists. + + `_register_vertexai_models` only auto-registers rows whose provider is + 'VertexAI', and the `claude-opus-5-5` row declares provider='Anthropic', + so without the explicit override the bare id would fall through to the + direct `anthropic.Anthropic` class. That regression shipped once for Opus 5 + and had to be repaired afterwards; pin it so it cannot recur. + + Unlike Opus 4.6/4.7/4.8, Opus 5.5 registers no `@latest` alias: Vertex AI + publishes only `claude-opus-5-5@default`, so the `@latest` id is + unverifiable. Opus 5.5 mirrors `claude-opus-5` instead. + """ + self.assertIsInstance( + lf.LanguageModel.get('claude-opus-5-5'), + vertexai.VertexAIClaude55Opus, + ) + with self.assertRaises(ValueError): + lf.LanguageModel.get('claude-opus-5-5@latest') + + @mock.patch.object(vertexai.VertexAI, 'credentials', new=True) + def test_vertexai_claude55_opus_global_default(self): + """Verifies that Opus 5.5 defaults to 'global' and uses correct host.""" + model = vertexai.VertexAIClaude55Opus(project='test') + self.assertEqual(model.location, 'global') + self.assertTrue(model._api_initialized) + self.assertIn('https://aiplatform.googleapis.com', model.api_endpoint) + self.assertNotIn('global-aiplatform', model.api_endpoint) + def test_thinking_param_true_adaptive_vertexai(self): """VertexAI Claude 4.7 + thinking=True -> adaptive thinking.""" lm = vertexai.VertexAIClaude47Opus(