Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions langfun/core/llms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 42 additions & 1 deletion langfun/core/llms/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
193 changes: 192 additions & 1 deletion langfun/core/llms/anthropic_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions langfun/core/llms/vertexai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions langfun/core/llms/vertexai_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading