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(