From feb11e45cb67ce5f9c2e9d39fdf84e8771cb7ed0 Mon Sep 17 00:00:00 2001 From: Javier Martinez Date: Wed, 3 Jun 2026 09:23:05 +0200 Subject: [PATCH] fix: update claude api spec (#2246) * feat: allow to update claude api spec automatically * feat: script to update sdk / openapi from claude * feat: add xhigh reasoning * feat: add mid system content block --- .github/workflows/update-claude-specs.yml | 41 ++++++ Makefile | 5 +- private_gpt/chat/input_models.py | 11 +- .../chat/models/chat_config_models.py | 2 +- private_gpt/components/llm/custom/openai.py | 1 + .../llm/decorators/reasoning_budget.py | 1 + private_gpt/components/llm/models.py | 1 + .../model_discovery/providers/lmstudio.py | 1 + private_gpt/events/models/__init__.py | 3 + private_gpt/events/models/_content_blocks.py | 8 ++ private_gpt/events/models/_message.py | 11 ++ .../server/chat/chat_request_mapper.py | 2 +- private_gpt/server/models/models_service.py | 1 + pyproject.toml | 2 +- scripts/update_claude_specs.py | 121 ++++++++++++++++++ .../anthropic/openapi_drift_whitelist.json | 13 +- tests/models/anthropic/registry.py | 29 ++++- tests/models/anthropic/test_field_parity.py | 7 +- tests/models/anthropic/test_openapi_schema.py | 2 +- tests/server/chat/test_input_models.py | 44 +++++++ uv.lock | 8 +- 21 files changed, 297 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/update-claude-specs.yml create mode 100644 scripts/update_claude_specs.py diff --git a/.github/workflows/update-claude-specs.yml b/.github/workflows/update-claude-specs.yml new file mode 100644 index 00000000..c85444ce --- /dev/null +++ b/.github/workflows/update-claude-specs.yml @@ -0,0 +1,41 @@ +name: update-claude-specs + +on: + schedule: + - cron: "0 3 * * *" # daily at 03:00 UTC + workflow_dispatch: + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Install UV and set Python version + uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + + - name: Update Claude specs and lock file + run: uv run python scripts/update_claude_specs.py + + - name: Create draft PR + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore(deps): update Claude specs and anthropic SDK" + branch: chore/update-claude-specs + delete-branch: true + draft: true + title: "chore(deps): update Claude specs and anthropic SDK" + body: | + Automated update of: + - `OPENAPI_SPEC_URL` in `tests/models/anthropic/test_openapi_schema.py` — fetched from [`anthropic-sdk-typescript/.stats.yml`](https://raw.githubusercontent.com/anthropics/anthropic-sdk-typescript/main/.stats.yml) + - `anthropic` minimum version in `pyproject.toml` — fetched from [PyPI](https://pypi.org/pypi/anthropic/json) + - `uv.lock` re-resolved via `uv lock` + + _Opened automatically by the `update-claude-specs` workflow — review and merge when tests pass._ + labels: dependencies diff --git a/Makefile b/Makefile index 14c35bc7..72594c08 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PROD_BINARY ?= .venv/bin/private-gpt PROD_ARGS ?= serve PROD_UV_CACHE_DIR ?= .uv-cache -.PHONY: test test-coverage black ruff format mypy check auto-discover-models run dev-windows dev prod-run api-docs docs ingest wipe celery flower +.PHONY: test test-coverage black ruff format mypy check auto-discover-models update-openapi-spec run dev-windows dev prod-run api-docs docs ingest wipe celery flower ######################################################################################################################## # Quality checks @@ -45,6 +45,9 @@ check: auto-discover-models: uv run python scripts/auto_discover_models.py $(AUTO_DISCOVER_ARGS) +update-openapi-spec: + uv run python scripts/update_claude_openapi.py + ######################################################################################################################## # Run diff --git a/private_gpt/chat/input_models.py b/private_gpt/chat/input_models.py index 7b4b1765..62cf6e6a 100644 --- a/private_gpt/chat/input_models.py +++ b/private_gpt/chat/input_models.py @@ -38,6 +38,7 @@ from private_gpt.events.models import ( CacheControlEphemeral, ContentBlockType, ImageBlock, + MidConvSystemBlock, TextBlock, TLDRBlock, ToolResultBlock, @@ -275,7 +276,7 @@ class Thinking(BaseModel): default=False, description="Enable reasoning capabilities for the model, allowing it to think step-by-step", ) - effort: Literal["low", "medium", "high", "max"] | None = Field( + effort: Literal["low", "medium", "high", "max", "xhigh"] | None = Field( default=None, deprecated=True, description=( @@ -324,7 +325,7 @@ class JsonObjectFormat(BaseModel): class OutputConfigInput(BaseModel): """Output configuration shared across Anthropic-compatible request models.""" - effort: Literal["low", "medium", "high", "max"] | None = Field( + effort: Literal["low", "medium", "high", "max", "xhigh"] | None = Field( default=None, description="Reasoning effort level for output generation.", ) @@ -339,7 +340,7 @@ class OutputConfigInput(BaseModel): class MessageInput(BaseModel): """Input message for AI conversations.""" - role: Literal["assistant", "user"] = Field( + role: Literal["assistant", "user", "system"] = Field( description="The role of the message sender" ) content: str | list[ @@ -877,6 +878,9 @@ class MessageInput(BaseModel): for block in content: if isinstance(block, TextBlock): blocks.append(LITextBlock(text=block.text)) + elif isinstance(block, MidConvSystemBlock): + text = "\n".join(b.text for b in block.content) + blocks.append(LITextBlock(text=text)) elif isinstance(block, ImageBlock) and isinstance( block.source, Base64ImageSource ): @@ -1340,6 +1344,7 @@ class EffortCapabilityOutput(BaseModel): medium: CapabilitySupportOutput = Field(description='Support for effort "medium".') high: CapabilitySupportOutput = Field(description='Support for effort "high".') max: CapabilitySupportOutput = Field(description='Support for effort "max".') + xhigh: CapabilitySupportOutput = Field(description='Support for effort "xhigh".') class ContextManagementCapabilityOutput(BaseModel): diff --git a/private_gpt/components/chat/models/chat_config_models.py b/private_gpt/components/chat/models/chat_config_models.py index cbc0de39..2f22230f 100644 --- a/private_gpt/components/chat/models/chat_config_models.py +++ b/private_gpt/components/chat/models/chat_config_models.py @@ -377,7 +377,7 @@ class ThinkingConfig(BaseModel): default=False, description="Whether to enable reasoning in the chat.", ) - type: Literal["low", "medium", "high", "max"] | None = Field( + type: Literal["low", "medium", "high", "max", "xhigh"] | None = Field( default="medium", description="The level of reasoning to use in the chat.", ) diff --git a/private_gpt/components/llm/custom/openai.py b/private_gpt/components/llm/custom/openai.py index 69ddbe99..c2a0d13b 100644 --- a/private_gpt/components/llm/custom/openai.py +++ b/private_gpt/components/llm/custom/openai.py @@ -50,6 +50,7 @@ if not TYPE_CHECKING: _CUSTOM_REASONING_EFFORT_MAPPING = { ReasoningEffort.NONE: "none", ReasoningEffort.MAX: "xhigh", + ReasoningEffort.XHIGH: "xhigh", } diff --git a/private_gpt/components/llm/decorators/reasoning_budget.py b/private_gpt/components/llm/decorators/reasoning_budget.py index a2786ade..86795fd9 100644 --- a/private_gpt/components/llm/decorators/reasoning_budget.py +++ b/private_gpt/components/llm/decorators/reasoning_budget.py @@ -22,6 +22,7 @@ _EFFORT_BUDGET_RATIO: dict[ReasoningEffort, float] = { ReasoningEffort.MEDIUM: 0.8, ReasoningEffort.HIGH: 0.8, ReasoningEffort.MAX: 0.8, + ReasoningEffort.XHIGH: 0.8, } diff --git a/private_gpt/components/llm/models.py b/private_gpt/components/llm/models.py index 2492b89d..31a21959 100644 --- a/private_gpt/components/llm/models.py +++ b/private_gpt/components/llm/models.py @@ -7,6 +7,7 @@ class ReasoningEffort(enum.StrEnum): MEDIUM = "medium" HIGH = "high" MAX = "max" + XHIGH = "xhigh" @classmethod def from_str(cls, effort_str: str) -> "ReasoningEffort": diff --git a/private_gpt/components/model_discovery/providers/lmstudio.py b/private_gpt/components/model_discovery/providers/lmstudio.py index c972b159..a78936ee 100644 --- a/private_gpt/components/model_discovery/providers/lmstudio.py +++ b/private_gpt/components/model_discovery/providers/lmstudio.py @@ -107,6 +107,7 @@ class LmStudioStrategy: "medium": supported if "medium" in effort else unsupported, "high": supported if "high" in effort else unsupported, "max": unsupported, + "xhigh": unsupported, }, "image_input": {"supported": vision, "maximum": 1 if vision else 0}, "audio_input": {"supported": False, "maximum": 0}, diff --git a/private_gpt/events/models/__init__.py b/private_gpt/events/models/__init__.py index 7f968a1e..5ff86416 100644 --- a/private_gpt/events/models/__init__.py +++ b/private_gpt/events/models/__init__.py @@ -18,6 +18,7 @@ from private_gpt.events.models._content_blocks import ( ContainerUploadBlock, DocumentBlock, ImageBlock, + MidConvSystemBlock, RedactedThinkingBlock, ResourceBlock, ResourceLinkBlock, @@ -89,6 +90,7 @@ _types = [ ContainerUploadBlock, DocumentBlock, ImageBlock, + MidConvSystemBlock, RedactedThinkingBlock, SearchResultBlock, ServerToolUseBlock, @@ -153,6 +155,7 @@ __all__ = [ "InputJSONDelta", "Message", "MessageOutputDelta", + "MidConvSystemBlock", "PingEvent", "RawContentBlockDeltaEvent", "RawContentBlockStartEvent", diff --git a/private_gpt/events/models/_content_blocks.py b/private_gpt/events/models/_content_blocks.py index dec5df7b..857122c0 100644 --- a/private_gpt/events/models/_content_blocks.py +++ b/private_gpt/events/models/_content_blocks.py @@ -458,6 +458,13 @@ class SearchResultBlock(CacheableContentBlock, StandardContentProtocol): citations: list[ZylonCitation] | None = Field(default=None) +class MidConvSystemBlock(CacheableContentBlock, StandardContentProtocol): + """System instructions injected at a specific point mid-conversation.""" + + type: Literal["mid_conv_system"] = Field(default="mid_conv_system") + content: list[TextBlock] = Field(description="System instruction text blocks.") + + # -------------------------------- # Custom Zylon Blocks # -------------------------------- @@ -578,6 +585,7 @@ BasicContentBlockType = ( | ContainerUploadBlock | DocumentBlock | SearchResultBlock + | MidConvSystemBlock ) diff --git a/private_gpt/events/models/_message.py b/private_gpt/events/models/_message.py index f44cf25a..9e8e9a4c 100644 --- a/private_gpt/events/models/_message.py +++ b/private_gpt/events/models/_message.py @@ -9,6 +9,14 @@ from private_gpt.events.models._base import StandardContentProtocol from private_gpt.events.models._tool_result_blocks import ContentBlockType +class OutputTokensDetails(BaseModel): + """Breakdown of output tokens by category.""" + + reasoning_tokens: int | None = Field( + default=None, description="Output tokens spent on internal reasoning." + ) + + class CacheCreation(BaseModel): """Token counts cached by ephemeral policy.""" @@ -67,6 +75,9 @@ class Usage(BaseModel): output_tokens: int | None = Field( default=None, description="Output token count for this response." ) + output_tokens_details: OutputTokensDetails | None = Field( + default=None, description="Breakdown of output tokens by category." + ) server_tool_use: ServerToolUsage | None = Field( default=None, description="Usage counters for server-side tool calls." ) diff --git a/private_gpt/server/chat/chat_request_mapper.py b/private_gpt/server/chat/chat_request_mapper.py index ce5073f8..b11dbf2c 100644 --- a/private_gpt/server/chat/chat_request_mapper.py +++ b/private_gpt/server/chat/chat_request_mapper.py @@ -152,7 +152,7 @@ class ChatRequestMapper: or bool(request.output_config and request.output_config.effort) or bool(request.thinking.effort) ) - effort: Literal["low", "medium", "high", "max"] | None = ( + effort: Literal["low", "medium", "high", "max", "xhigh"] | None = ( request.output_config.effort if request.output_config is not None and request.output_config.effort is not None diff --git a/private_gpt/server/models/models_service.py b/private_gpt/server/models/models_service.py index fe3d1cf7..941ecf9b 100644 --- a/private_gpt/server/models/models_service.py +++ b/private_gpt/server/models/models_service.py @@ -76,6 +76,7 @@ class ModelsService: medium=supported if thinking_enabled else unsupported, high=supported if thinking_enabled else unsupported, max=supported if thinking_enabled else unsupported, + xhigh=supported if thinking_enabled else unsupported, ), image_input=CountCapabilitySupportOutput( supported=supports_image_input, maximum=config.support_image or 0 diff --git a/pyproject.toml b/pyproject.toml index 70dcb247..5edab15e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ llm-mistral = [ ] llm-anthropic = [ "langchain-anthropic>=1.4.3", - "anthropic>=0.97.0", + "anthropic>=0.105.2", ] # --------------------------------------------------------------------------- diff --git a/scripts/update_claude_specs.py b/scripts/update_claude_specs.py new file mode 100644 index 00000000..e7e5674d --- /dev/null +++ b/scripts/update_claude_specs.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Keep Claude-related specs in sync: OpenAPI spec URL and anthropic SDK version.""" + +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).parent.parent + +ANTHROPIC_STATS_URL = "https://raw.githubusercontent.com/anthropics/anthropic-sdk-typescript/main/.stats.yml" +ANTHROPIC_PYPI_URL = "https://pypi.org/pypi/anthropic/json" + +OPENAPI_TEST_FILE = ROOT / "tests/models/anthropic/test_openapi_schema.py" +PYPROJECT_FILE = ROOT / "pyproject.toml" + + +def fetch_openapi_spec_url() -> str: + with urllib.request.urlopen(ANTHROPIC_STATS_URL, timeout=15) as resp: + for line in resp.read().decode().splitlines(): + if line.startswith("openapi_spec_url:"): + spec_url: str = line.split(":", 1)[1].strip() + return spec_url + raise RuntimeError(f"openapi_spec_url not found in {ANTHROPIC_STATS_URL}") + + +def fetch_latest_anthropic_version() -> str: + with urllib.request.urlopen(ANTHROPIC_PYPI_URL, timeout=15) as resp: + version: str = json.loads(resp.read().decode())["info"]["version"] + return version + + +def current_openapi_spec_url() -> str: + source = OPENAPI_TEST_FILE.read_text(encoding="utf-8") + match = re.search(r'^OPENAPI_SPEC_URL\s*=\s*"([^"]*)"', source, re.MULTILINE) + spec_url: str = match.group(1) if match else "" + return spec_url + + +def current_anthropic_version() -> str: + source = PYPROJECT_FILE.read_text(encoding="utf-8") + match = re.search(r'"anthropic>=([\d.]+)"', source) + return match.group(1) if match else "" + + +def update_openapi_spec_url(new_url: str) -> None: + source = OPENAPI_TEST_FILE.read_text(encoding="utf-8") + updated, count = re.subn( + r'^(OPENAPI_SPEC_URL\s*=\s*")[^"]*(")', + rf"\g<1>{new_url}\g<2>", + source, + flags=re.MULTILINE, + ) + if count == 0: + raise RuntimeError( + f"OPENAPI_SPEC_URL assignment not found in {OPENAPI_TEST_FILE}" + ) + OPENAPI_TEST_FILE.write_text(updated, encoding="utf-8") + + +def update_anthropic_version(new_version: str) -> None: + source = PYPROJECT_FILE.read_text(encoding="utf-8") + updated, count = re.subn( + r'"anthropic>=[\d.]+"', + f'"anthropic>={new_version}"', + source, + ) + if count == 0: + raise RuntimeError(f"anthropic dependency not found in {PYPROJECT_FILE}") + PYPROJECT_FILE.write_text(updated, encoding="utf-8") + + +def sync_lock_file() -> None: + print("\nRunning uv lock ...") + subprocess.run(["uv", "lock"], check=True, cwd=ROOT) + + +def main() -> None: + changed = False + + # --- OpenAPI spec URL --- + print(f"Fetching OpenAPI spec URL from {ANTHROPIC_STATS_URL} ...") + latest_url = fetch_openapi_spec_url() + pinned_url = current_openapi_spec_url() + + if pinned_url == latest_url: + print(" OpenAPI spec URL already up to date.") + else: + rel = OPENAPI_TEST_FILE.relative_to(ROOT) + print(f" Updating {rel}") + print(f" {pinned_url}") + print(f" -> {latest_url}") + update_openapi_spec_url(latest_url) + changed = True + + # --- anthropic SDK version --- + print(f"\nFetching latest anthropic version from {ANTHROPIC_PYPI_URL} ...") + latest_version = fetch_latest_anthropic_version() + pinned_version = current_anthropic_version() + + if pinned_version == latest_version: + print(f" anthropic already at {latest_version}.") + else: + print(f" Updating {PYPROJECT_FILE.name}") + print(f" anthropic>={pinned_version}") + print(f" -> anthropic>={latest_version}") + update_anthropic_version(latest_version) + changed = True + + if not changed: + print("\nAll specs up to date.") + sys.exit(0) + + sync_lock_file() + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/tests/models/anthropic/openapi_drift_whitelist.json b/tests/models/anthropic/openapi_drift_whitelist.json index 3b0c990a..10f20899 100644 --- a/tests/models/anthropic/openapi_drift_whitelist.json +++ b/tests/models/anthropic/openapi_drift_whitelist.json @@ -114,7 +114,11 @@ "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:tool_use].properties.caller.oneOf[type:code_execution_20250825].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:tool_use].properties.caller.oneOf[type:code_execution_20260120].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:tool_use].properties.caller.oneOf[type:direct].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", - "CountTokensInput.properties.output_config.properties.format.additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape." + "CountTokensInput.properties.output_config.properties.format.additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.cache_control.oneOf[type:ephemeral].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.content.items.discriminator: only_remote": "Remote wraps mid_conv_system content items in a oneOf+discriminator; local list[TextBlock] emits a plain schema without discriminator.", + "CountTokensInput.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.content.items.oneOf: only_remote": "Remote wraps mid_conv_system content items in a oneOf+discriminator; local list[TextBlock] emits a plain schema without oneOf." }, "CountTokensOutput": {}, "ChatBody": { @@ -216,7 +220,12 @@ "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:tool_use].properties.caller.oneOf[type:code_execution_20260120].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:tool_use].properties.caller.oneOf[type:direct].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", "ChatBody.properties.metadata.additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", - "ChatBody.properties.output_config.properties.format.additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape." + "ChatBody.properties.output_config.properties.format.additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "ChatBody.properties.max_tokens.minimum: type float!=int": "Local schema uses float for max_tokens minimum; Anthropic remote schema uses int.", + "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.cache_control.oneOf[type:ephemeral].additionalProperties: True!=False": "Local runtime intentionally allows additional properties to preserve Zylon extensions and forward-compatible client metadata while keeping the Anthropic-compatible core shape.", + "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.content.items.discriminator: only_remote": "Remote wraps mid_conv_system content items in a oneOf+discriminator; local list[TextBlock] emits a plain schema without discriminator.", + "ChatBody.properties.messages.items.properties.content.anyOf[0].items.oneOf[type:mid_conv_system].properties.content.items.oneOf: only_remote": "Remote wraps mid_conv_system content items in a oneOf+discriminator; local list[TextBlock] emits a plain schema without oneOf." } } } diff --git a/tests/models/anthropic/registry.py b/tests/models/anthropic/registry.py index 6b916169..4eff65cc 100644 --- a/tests/models/anthropic/registry.py +++ b/tests/models/anthropic/registry.py @@ -17,6 +17,7 @@ from private_gpt.events.models import ( InputJSONDelta, Message, MessageOutputDelta, + MidConvSystemBlock, PingEvent, RawContentBlockDeltaEvent, RawContentBlockStartEvent, @@ -57,6 +58,10 @@ class TypeMapping: sdk_sample: dict[str, Any] notes: str = "" skip: bool = False + # Fields present in the remote OpenAPI schema and our type but not yet in the + # Python SDK (SDK is lagging behind the spec). Not stripped during schema + # validation, not flagged as undeclared extensions. + forward_compat_fields: frozenset[str] = frozenset() _BASE_ZYLON_FIELDS: frozenset[str] = frozenset( @@ -262,6 +267,7 @@ STREAMING_EVENT_REGISTRY: list[TypeMapping] = [ "role": "assistant", "content": [], "model": "claude-sonnet-4-6", + "stop_details": None, "stop_reason": "end_turn", "stop_sequence": None, "container": None, @@ -272,6 +278,7 @@ STREAMING_EVENT_REGISTRY: list[TypeMapping] = [ "inference_geo": None, "input_tokens": 10, "output_tokens": 0, + "output_tokens_details": None, "server_tool_use": None, "service_tier": None, }, @@ -289,6 +296,7 @@ STREAMING_EVENT_REGISTRY: list[TypeMapping] = [ "type": "message_delta", "delta": { "container": None, + "stop_details": None, "stop_reason": "end_turn", "stop_sequence": None, }, @@ -297,6 +305,7 @@ STREAMING_EVENT_REGISTRY: list[TypeMapping] = [ "cache_read_input_tokens": None, "input_tokens": None, "output_tokens": 42, + "output_tokens_details": None, "server_tool_use": None, }, }, @@ -334,7 +343,7 @@ MESSAGE_REGISTRY: list[TypeMapping] = [ our_type=Message, sdk_schema_name="Message", openapi_schema_name=None, - zylon_only_fields=frozenset({"cache_control", "stop_details"}), + zylon_only_fields=frozenset({"cache_control"}), sdk_only_fields=frozenset(), sdk_sample={ "id": "msg_01abc", @@ -353,6 +362,7 @@ MESSAGE_REGISTRY: list[TypeMapping] = [ "inference_geo": None, "input_tokens": 10, "output_tokens": 5, + "output_tokens_details": None, "server_tool_use": None, "service_tier": None, }, @@ -365,6 +375,7 @@ MESSAGE_REGISTRY: list[TypeMapping] = [ openapi_schema_name=None, zylon_only_fields=frozenset(), sdk_only_fields=_USAGE_SDK_ONLY, + forward_compat_fields=frozenset({"output_tokens_details"}), sdk_sample={ "cache_creation": None, "cache_creation_input_tokens": None, @@ -372,6 +383,7 @@ MESSAGE_REGISTRY: list[TypeMapping] = [ "inference_geo": None, "input_tokens": 42, "output_tokens": 17, + "output_tokens_details": None, "server_tool_use": None, "service_tier": None, }, @@ -430,6 +442,19 @@ ZYLON_ONLY_REGISTRY: list[TypeMapping] = [ notes="Zylon-only search result content block", skip=True, ), + TypeMapping( + sdk_type=None, + our_type=MidConvSystemBlock, + sdk_schema_name="RequestMidConvSystemBlock", + openapi_schema_name="RequestMidConvSystemBlock", + zylon_only_fields=_CACHEABLE_ZYLON_FIELDS, + sdk_only_fields=frozenset(), + sdk_sample={ + "type": "mid_conv_system", + "content": [{"type": "text", "text": "You are a helpful assistant."}], + }, + notes="In the Anthropic OpenAPI spec (InputContentBlock) but not yet in the Python SDK", + ), TypeMapping( sdk_type=None, our_type=BinaryBlock, @@ -540,7 +565,7 @@ ZYLON_ONLY_REGISTRY: list[TypeMapping] = [ sdk_schema_name="MessageDelta", openapi_schema_name=None, zylon_only_fields=_BASE_ZYLON_FIELDS - | {"id", "type", "role", "content", "model", "usage", "stop_details"}, + | {"id", "type", "role", "content", "model", "usage"}, sdk_only_fields=frozenset(), sdk_sample={ "container": None, diff --git a/tests/models/anthropic/test_field_parity.py b/tests/models/anthropic/test_field_parity.py index 9062ca41..3cbb2610 100644 --- a/tests/models/anthropic/test_field_parity.py +++ b/tests/models/anthropic/test_field_parity.py @@ -152,7 +152,12 @@ class TestFieldParity: sdk_names = _sdk_field_names(mapping) our_names = _our_field_names(mapping) - extras = our_names - sdk_names - mapping.zylon_only_fields + extras = ( + our_names + - sdk_names + - mapping.zylon_only_fields + - mapping.forward_compat_fields + ) if extras: failures.append( diff --git a/tests/models/anthropic/test_openapi_schema.py b/tests/models/anthropic/test_openapi_schema.py index 57906cbf..13c9665e 100644 --- a/tests/models/anthropic/test_openapi_schema.py +++ b/tests/models/anthropic/test_openapi_schema.py @@ -8,7 +8,7 @@ import pytest from tests.models.anthropic.registry import ALL_MAPPINGS, TypeMapping -OPENAPI_SPEC_URL = "https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic%2Fanthropic-dd2dcd00a757075370a7e4a7f469a1e2d067c2118684c3b70d7906a8f5cf518b.yml" +OPENAPI_SPEC_URL = "https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-3044bdebca823a5e350accb2a228438e6ca5fb06cbac2cb1e0f98baa2bcc2359.yml" OPENAPI_DRIFT_WHITELIST_PATH = Path(__file__).with_name("openapi_drift_whitelist.json") diff --git a/tests/server/chat/test_input_models.py b/tests/server/chat/test_input_models.py index 01958dc9..71ee500d 100644 --- a/tests/server/chat/test_input_models.py +++ b/tests/server/chat/test_input_models.py @@ -14,6 +14,7 @@ from private_gpt.components.chunk.models import Chunk from private_gpt.components.engines.citations.utils import process_history_citations from private_gpt.events.models import ( ImageBlock, + MidConvSystemBlock, SourceBlock, TextBlock, ThinkingBlock, @@ -47,6 +48,49 @@ def test_simple_message_conversion( assert tool_uses == {} +def test_user_message_with_mid_conv_system_block() -> None: + message = MessageInput( + role="user", + content=[ + TextBlock(type="text", text="Hello"), + MidConvSystemBlock( + content=[ + TextBlock(type="text", text="Be concise."), + TextBlock(type="text", text="Use bullet points."), + ] + ), + ], + ) + result, _ = message._convert_into_llama_index_messages() + + assert len(result) == 1 + assert result[0].role == MessageRole.USER + blocks = result[0].blocks + assert len(blocks) == 2 + assert isinstance(blocks[0], LITextBlock) + assert blocks[0].text == "Hello" + assert isinstance(blocks[1], LITextBlock) + assert blocks[1].text == "Be concise.\nUse bullet points." + + +def test_user_message_mid_conv_system_single_text_block() -> None: + message = MessageInput( + role="user", + content=[ + MidConvSystemBlock( + content=[TextBlock(type="text", text="Focus on safety.")] + ), + ], + ) + result, _ = message._convert_into_llama_index_messages() + + assert len(result) == 1 + blocks = result[0].blocks + assert len(blocks) == 1 + assert isinstance(blocks[0], LITextBlock) + assert blocks[0].text == "Focus on safety." + + def test_assistant_message_with_text_block() -> None: text_block = TextBlock(type="text", text="Hello from assistant") message = MessageInput(role="assistant", content=[text_block]) diff --git a/uv.lock b/uv.lock index c21c0108..38339673 100644 --- a/uv.lock +++ b/uv.lock @@ -132,7 +132,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.98.1" +version = "0.105.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -144,9 +144,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/60/a9e4426dfe594e5eec8a9757d48e3d8dcf529a0a35a4fc8aefa352bd95fe/anthropic-0.98.1.tar.gz", hash = "sha256:62205edec42f5877df63d58be8e9443843d3e032215836e228fba1f59514a433", size = 725085, upload-time = "2026-05-04T21:40:39.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/46/47581b8c689c743ceabf6a0f9ff48472160900ce802d26c0fb50423997b3/anthropic-0.105.2.tar.gz", hash = "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", size = 853789, upload-time = "2026-05-29T00:21:14.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/6f/7f7f80f714e6de0784518f1999f71fd632076aefd3e22fe0ccd27ca9571f/anthropic-0.98.1-py3-none-any.whl", hash = "sha256:107ebf954415382fdcea6a94f9cf334a53199ad64794403590dc55366cefcc28", size = 699604, upload-time = "2026-05-04T21:40:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/be0c357e33a5a56c8f9db5b4212f886138d2bf59c0952d858f6b75d710ef/anthropic-0.105.2-py3-none-any.whl", hash = "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230", size = 837507, upload-time = "2026-05-29T00:21:15.528Z" }, ] [[package]] @@ -3585,7 +3585,7 @@ vectorstore-qdrant = [ [package.metadata] requires-dist = [ { name = "aiobotocore", marker = "extra == 'storage-s3'", specifier = ">=3.6.0,<3.7" }, - { name = "anthropic", marker = "extra == 'llm-anthropic'", specifier = ">=0.97.0" }, + { name = "anthropic", marker = "extra == 'llm-anthropic'", specifier = ">=0.105.2" }, { name = "asyncpg", marker = "extra == 'database-postgres'", specifier = ">=0.31.0,<0.32" }, { name = "beautifulsoup4", marker = "extra == 'ingest-markup'", specifier = ">=4.14.3,<5" }, { name = "black", extras = ["jupyter"], marker = "extra == 'lint'", specifier = ">=22,<23" },