diff --git a/src/any_llm/providers/anthropic/utils.py b/src/any_llm/providers/anthropic/utils.py index 5c4b5602..c99da1fb 100644 --- a/src/any_llm/providers/anthropic/utils.py +++ b/src/any_llm/providers/anthropic/utils.py @@ -57,6 +57,45 @@ def _is_tool_call(message: dict[str, Any]) -> bool: return message["role"] == "assistant" and message.get("tool_calls") is not None +def _extract_anthropic_thinking_signature(message: dict[str, Any]) -> str | None: + """Extract the encrypted thinking signature stored on a message's extra_content, if any.""" + extra_content = message.get("extra_content") + if isinstance(extra_content, dict) and isinstance(anthropic_extra := extra_content.get("anthropic"), dict): + signature = anthropic_extra.get("signature") + if isinstance(signature, str): + return signature + return None + + +def _extract_reasoning_text(message: dict[str, Any]) -> str: + """Extract the plain-text reasoning content from a message, regardless of its shape. + + ``reasoning`` may be a plain string (the OpenAI-wire-compatible serialized form) or a + ``{"content": str}`` dict, depending on how the caller constructed the message. + """ + reasoning = message.get("reasoning") + if isinstance(reasoning, str): + return reasoning + if isinstance(reasoning, dict) and isinstance(content := reasoning.get("content"), str): + return content + return "" + + +def _build_anthropic_thinking_block(message: dict[str, Any]) -> dict[str, Any] | None: + """Reconstruct an Anthropic ``thinking`` content block for replay across turns. + + When extended thinking is enabled, Anthropic requires the ``thinking`` block (including + its encrypted ``signature``) to be passed back unmodified on subsequent turns, e.g. + alongside tool results. Without it, the model loses its original reasoning trace, which + can lead to degraded or repeated reasoning. See + https://docs.claude.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks + """ + signature = _extract_anthropic_thinking_signature(message) + if signature is None: + return None + return {"type": "thinking", "thinking": _extract_reasoning_text(message), "signature": signature} + + def _convert_content_for_anthropic(content: list[dict[str, Any]]) -> list[dict[str, Any]]: """Convert content blocks from OpenAI format to Anthropic format. - Parse the "content" field block by block @@ -122,9 +161,11 @@ def _convert_messages_for_anthropic(messages: list[dict[str, Any]]) -> tuple[str # See https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview#tool-use-examples if _is_tool_call(message): # Convert ALL tool calls from the assistant message - tool_use_blocks = [] + content_blocks: list[dict[str, Any]] = [] + if thinking_block := _build_anthropic_thinking_block(message): + content_blocks.append(thinking_block) for tool_call in message["tool_calls"]: - tool_use_blocks.append( + content_blocks.append( { "type": "tool_use", "id": tool_call["id"], @@ -134,7 +175,7 @@ def _convert_messages_for_anthropic(messages: list[dict[str, Any]]) -> tuple[str ) message = { "role": "assistant", - "content": tool_use_blocks, + "content": content_blocks, } elif message["role"] == "tool": # Use tool_call_id from the message itself @@ -157,6 +198,20 @@ def _convert_messages_for_anthropic(messages: list[dict[str, Any]]) -> tuple[str "role": "user", "content": [tool_result], } + elif message["role"] == "assistant" and (thinking_block := _build_anthropic_thinking_block(message)): + # existing_content may be None (a reasoning-only turn with no text/tool_calls), + # a plain string, or a list of content blocks. + existing_content = message.get("content") + content_blocks = [thinking_block] + if isinstance(existing_content, str): + if existing_content: + content_blocks.append({"type": "text", "text": existing_content}) + elif isinstance(existing_content, list): + content_blocks.extend(existing_content) + message = { + "role": "assistant", + "content": content_blocks, + } if "content" in message and isinstance(message["content"], list): message["content"] = _convert_content_for_anthropic(message["content"]) @@ -212,6 +267,11 @@ def _create_openai_chunk_from_anthropic_chunk(chunk: Any, model_id: str) -> Chat } elif chunk.delta.type == "thinking_delta": delta = {"reasoning": {"content": chunk.delta.thinking}} + elif chunk.delta.type == "signature_delta": + # The encrypted signature of the thinking block. Must be preserved unmodified + # and passed back to Anthropic on subsequent turns (e.g. alongside tool results) + # to maintain reasoning continuity. See https://docs.claude.com/en/docs/build-with-claude/extended-thinking + delta = {"extra_content": {"anthropic": {"signature": chunk.delta.signature}}} elif isinstance(chunk, ContentBlockStopEvent): if hasattr(chunk, "content_block") and chunk.content_block.type == "tool_use": @@ -254,6 +314,7 @@ def _convert_response(response: Message) -> ChatCompletion: content_parts: list[str] = [] tool_calls: list[ChatCompletionMessageFunctionToolCall | ChatCompletionMessageToolCall] = [] reasoning_content: str | None = None + thinking_signature: str | None = None for content_block in response.content: if content_block.type == "text": content_parts.append(content_block.text) @@ -273,6 +334,11 @@ def _convert_response(response: Message) -> ChatCompletion: reasoning_content = content_block.thinking else: reasoning_content += content_block.thinking + # The encrypted signature must be preserved and replayed unmodified on + # subsequent turns (e.g. alongside tool results) to maintain reasoning + # continuity. See https://docs.claude.com/en/docs/build-with-claude/extended-thinking + if content_block.signature: + thinking_signature = content_block.signature else: msg = f"Unsupported content block type: {content_block.type}" raise ValueError(msg) @@ -282,6 +348,7 @@ def _convert_response(response: Message) -> ChatCompletion: content="".join(content_parts), reasoning=Reasoning(content=reasoning_content) if reasoning_content else None, tool_calls=cast("list[ChatCompletionMessageToolCallType] | None", tool_calls or None), + extra_content={"anthropic": {"signature": thinking_signature}} if thinking_signature else None, ) cache_read = response.usage.cache_read_input_tokens or 0 diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index c33f003e..11ec902d 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -276,6 +276,18 @@ def _extract_usage_dict(response: types.GenerateContentResponse) -> dict[str, An return usage +def _thought_signature_extra_content(part: Any) -> dict[str, Any] | None: + """Build the OpenAI-compatible extra_content payload for a Gemini thought_signature, if present. + + Shared by both the non-streaming (_convert_response_to_response_dict) and streaming + (_create_openai_chunk_from_google_chunk) conversion paths so they stay in sync. + """ + thought_signature = getattr(part, "thought_signature", None) + if thought_signature is not None and isinstance(thought_signature, bytes): + return {"google": {"thought_signature": base64.b64encode(thought_signature).decode("utf-8")}} + return None + + def _convert_response_to_response_dict(response: types.GenerateContentResponse) -> dict[str, Any]: response_dict = { "id": "google_genai_response", @@ -315,11 +327,8 @@ def _convert_response_to_response_dict(response: types.GenerateContentResponse) } # Include thought_signature if present (OpenAI compatibility format) - thought_signature = getattr(part, "thought_signature", None) - if thought_signature is not None and isinstance(thought_signature, bytes): - tool_call_dict["extra_content"] = { - "google": {"thought_signature": base64.b64encode(thought_signature).decode("utf-8")} - } + if extra_content := _thought_signature_extra_content(part): + tool_call_dict["extra_content"] = extra_content tool_calls_list.append(tool_call_dict) elif getattr(part, "text", None): @@ -405,6 +414,10 @@ def _create_openai_chunk_from_google_chunk( for key, value in args.items(): args_dict[key] = value + # Include thought_signature if present (OpenAI compatibility format), mirroring + # the non-streaming conversion in _convert_response_to_response_dict. + extra_content = _thought_signature_extra_content(part) + tool_calls_list.append( ChoiceDeltaToolCall( index=len(tool_calls_list), @@ -414,6 +427,7 @@ def _create_openai_chunk_from_google_chunk( name=function_call.name, arguments=json.dumps(args_dict), ), + extra_content=extra_content, ) ) elif part.text: diff --git a/src/any_llm/types/completion.py b/src/any_llm/types/completion.py index e98ea56f..c7950246 100644 --- a/src/any_llm/types/completion.py +++ b/src/any_llm/types/completion.py @@ -58,6 +58,15 @@ def _serialize(self) -> str: class ChatCompletionMessage(OpenAIChatCompletionMessage): reasoning: Reasoning | None = None annotations: list[dict[str, Any]] | None = None # type: ignore[assignment] + extra_content: dict[str, Any] | None = None + """Provider-specific metadata that needs to be preserved across multi-turn conversations. + + For example, Anthropic's extended thinking requires the encrypted ``signature`` of a + ``thinking`` block to be passed back unmodified alongside subsequent tool calls. + + Example extra_content structure for Anthropic: + {"anthropic": {"signature": ""}} + """ class Choice(OpenAIChoice): @@ -84,8 +93,26 @@ class ParsedChatCompletion(ChatCompletion, Generic[ContentType]): choices: list[ParsedChoice[ContentType]] # type: ignore[assignment] +class ChoiceDeltaToolCall(OpenAIChoiceDeltaToolCall): + """Streaming counterpart of ``ChatCompletionMessageFunctionToolCall``. + + Adds the same ``extra_content`` field so provider-specific tool-call metadata (e.g. + Gemini's ``thought_signature``) can be carried on streaming deltas, not just on the + final non-streaming tool call. + """ + + extra_content: dict[str, Any] | None = None + + class ChoiceDelta(OpenAIChoiceDelta): reasoning: Reasoning | None = None + tool_calls: list[ChoiceDeltaToolCall] | None = None # type: ignore[assignment] + extra_content: dict[str, Any] | None = None + """Streaming counterpart of ``ChatCompletionMessage.extra_content``. + + Carries provider-specific metadata (e.g. Anthropic's thinking block ``signature``) + that arrives as part of a streaming delta rather than the final message. + """ class ChunkChoice(OpenAIChunkChoice): @@ -118,7 +145,6 @@ class ChatCompletionMessageFunctionToolCall(OpenAIChatCompletionMessageFunctionT CreateEmbeddingResponse = OpenAICreateEmbeddingResponse Embedding = OpenAIEmbedding Usage = OpenAIUsage -ChoiceDeltaToolCall = OpenAIChoiceDeltaToolCall ChoiceDeltaToolCallFunction = OpenAIChoiceDeltaToolCallFunction ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max", "auto"] diff --git a/tests/unit/providers/test_anthropic_provider.py b/tests/unit/providers/test_anthropic_provider.py index ea5e2947..30e011d9 100644 --- a/tests/unit/providers/test_anthropic_provider.py +++ b/tests/unit/providers/test_anthropic_provider.py @@ -909,6 +909,40 @@ def test_streaming_tool_chunks_preserve_parallel_tool_index() -> None: assert delta_result.choices[0].delta.tool_calls[0].function.arguments == '{"city":"Rome"}' +def test_streaming_thinking_signature_delta_sets_extra_content() -> None: + """The encrypted thinking signature must be surfaced on the streaming delta's extra_content.""" + from anthropic.types import ContentBlockDeltaEvent, SignatureDelta + + from any_llm.providers.anthropic.utils import _create_openai_chunk_from_anthropic_chunk + + delta_chunk = ContentBlockDeltaEvent( + type="content_block_delta", + index=0, + delta=SignatureDelta(type="signature_delta", signature="sig-12345"), + ) + result = _create_openai_chunk_from_anthropic_chunk(delta_chunk, "claude-3-haiku") + + assert result.choices[0].delta.extra_content == {"anthropic": {"signature": "sig-12345"}} + + +def test_streaming_thinking_delta_does_not_set_extra_content() -> None: + """A plain thinking_delta (no signature yet) should not set extra_content.""" + from anthropic.types import ContentBlockDeltaEvent, ThinkingDelta + + from any_llm.providers.anthropic.utils import _create_openai_chunk_from_anthropic_chunk + + delta_chunk = ContentBlockDeltaEvent( + type="content_block_delta", + index=0, + delta=ThinkingDelta(type="thinking_delta", thinking="Let me think..."), + ) + result = _create_openai_chunk_from_anthropic_chunk(delta_chunk, "claude-3-haiku") + + assert result.choices[0].delta.extra_content is None + assert result.choices[0].delta.reasoning is not None + assert result.choices[0].delta.reasoning.content == "Let me think..." + + def test_non_streaming_response_preserves_multiple_tool_calls() -> None: from anthropic.types import Message, ToolUseBlock, Usage @@ -942,6 +976,267 @@ def test_non_streaming_response_preserves_multiple_tool_calls() -> None: assert result.choices[0].message.tool_calls[1].function.name == "get_time" +def test_non_streaming_response_preserves_thinking_signature() -> None: + """The encrypted thinking signature must be surfaced on the message's extra_content.""" + from anthropic.types import Message, ThinkingBlock, ToolUseBlock, Usage + + from any_llm.providers.anthropic.utils import _convert_response + + response = Message( + id="msg_123", + type="message", + role="assistant", + model="claude-3-haiku", + stop_reason="tool_use", + content=[ + ThinkingBlock(type="thinking", thinking="Let me reason...", signature="sig-12345"), + ToolUseBlock(type="tool_use", id="toolu_1", name="get_weather", input={"city": "Rome"}), + ], + usage=Usage(input_tokens=10, output_tokens=5), + ) + + result = _convert_response(response) + + assert result.choices[0].message.reasoning is not None + assert result.choices[0].message.reasoning.content == "Let me reason..." + assert result.choices[0].message.extra_content == {"anthropic": {"signature": "sig-12345"}} + + +def test_non_streaming_response_without_thinking_has_no_extra_content() -> None: + from anthropic.types import Message, TextBlock, Usage + + from any_llm.providers.anthropic.utils import _convert_response + + response = Message( + id="msg_123", + type="message", + role="assistant", + model="claude-3-haiku", + stop_reason="end_turn", + content=[TextBlock(type="text", text="Hello")], + usage=Usage(input_tokens=10, output_tokens=5), + ) + + result = _convert_response(response) + + assert result.choices[0].message.extra_content is None + + +def test_non_streaming_response_empty_thinking_signature_has_no_extra_content() -> None: + """An empty (falsy) signature, e.g. display='omitted' before the signature streams in, should not be stored.""" + from anthropic.types import Message, ThinkingBlock, Usage + + from any_llm.providers.anthropic.utils import _convert_response + + response = Message( + id="msg_123", + type="message", + role="assistant", + model="claude-3-haiku", + stop_reason="end_turn", + content=[ThinkingBlock(type="thinking", thinking="", signature="")], + usage=Usage(input_tokens=10, output_tokens=5), + ) + + result = _convert_response(response) + + assert result.choices[0].message.extra_content is None + + +def test_convert_messages_replays_thinking_block_with_tool_call() -> None: + """Anthropic requires the unmodified thinking block (with signature) to be replayed + alongside the tool_use block when continuing a turn that used extended thinking.""" + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "What's the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "reasoning": "Let me check the weather tool.", + "extra_content": {"anthropic": {"signature": "sig-12345"}}, + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "Paris"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": '{"temp": "20C"}'}, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assistant_message = converted[1] + assert assistant_message["role"] == "assistant" + assert assistant_message["content"][0] == { + "type": "thinking", + "thinking": "Let me check the weather tool.", + "signature": "sig-12345", + } + assert assistant_message["content"][1] == { + "type": "tool_use", + "id": "toolu_1", + "name": "get_weather", + "input": {"city": "Paris"}, + } + + +def test_convert_messages_replays_thinking_block_with_text() -> None: + """A plain-text assistant message that carries a thinking signature should also replay it. + + Also covers the dict-shaped ``{"content": str}`` form of ``reasoning``, as opposed to the + plain string form used in test_convert_messages_replays_thinking_block_with_tool_call. + """ + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "What is 2 + 2?"}, + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning": {"content": "2 + 2 = 4."}, + "extra_content": {"anthropic": {"signature": "sig-67890"}}, + }, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assistant_message = converted[1] + assert assistant_message["content"] == [ + {"type": "thinking", "thinking": "2 + 2 = 4.", "signature": "sig-67890"}, + {"type": "text", "text": "The answer is 4."}, + ] + + +def test_convert_messages_replays_thinking_block_with_list_content() -> None: + """An assistant message whose content is already a list of blocks (not a plain string) should + have the thinking block prepended to the existing blocks, not replace them.""" + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Describe this image."}, + { + "role": "assistant", + "content": [{"type": "text", "text": "It's a cat."}], + "reasoning": "Looking at the image.", + "extra_content": {"anthropic": {"signature": "sig-list"}}, + }, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assistant_message = converted[1] + assert assistant_message["content"] == [ + {"type": "thinking", "thinking": "Looking at the image.", "signature": "sig-list"}, + {"type": "text", "text": "It's a cat."}, + ] + + +def test_extract_anthropic_thinking_signature_ignores_non_string_signature() -> None: + """A malformed signature (non-string) should be treated as absent, not crash.""" + from any_llm.providers.anthropic.utils import _extract_anthropic_thinking_signature + + message = {"extra_content": {"anthropic": {"signature": 12345}}} + + assert _extract_anthropic_thinking_signature(message) is None + + +def test_build_anthropic_thinking_block_defaults_to_empty_thinking_text() -> None: + """When a signature is present but reasoning is missing or malformed, thinking text defaults to ''.""" + from any_llm.providers.anthropic.utils import _build_anthropic_thinking_block + + message = {"extra_content": {"anthropic": {"signature": "sig-no-reasoning"}}} + + assert _build_anthropic_thinking_block(message) == { + "type": "thinking", + "thinking": "", + "signature": "sig-no-reasoning", + } + + +def test_convert_messages_without_thinking_signature_unchanged() -> None: + """Without a signature, assistant messages should be forwarded unchanged (no thinking block).""" + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello there!"}, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assert converted[1] == {"role": "assistant", "content": "Hello there!"} + + +def test_convert_messages_ignores_unrelated_extra_content() -> None: + """An extra_content dict without an 'anthropic' key (e.g. from a different provider) should be a no-op.""" + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": "Hello there!", + "reasoning": "Greeting the user.", + "extra_content": {"google": {"thought_signature": "unrelated"}}, + }, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assert converted[1] == {"role": "assistant", "content": "Hello there!"} + + +def test_convert_messages_replays_thinking_block_with_none_content() -> None: + """A reasoning-only assistant turn (content=None, no tool_calls) must not crash on replay. + + Regression test: message.get("content") can be explicitly None rather than a string or + list, e.g. for a turn that only produced reasoning and no visible text or tool call. + """ + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Just think about it, don't answer."}, + { + "role": "assistant", + "content": None, + "reasoning": "Thinking without responding.", + "extra_content": {"anthropic": {"signature": "sig-none-content"}}, + }, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assistant_message = converted[1] + assert assistant_message["content"] == [ + {"type": "thinking", "thinking": "Thinking without responding.", "signature": "sig-none-content"}, + ] + + +def test_convert_messages_replays_thinking_block_with_empty_string_content() -> None: + """An empty string content (as opposed to None) should behave the same: only the thinking block is kept.""" + from any_llm.providers.anthropic.utils import _convert_messages_for_anthropic + + messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Just think about it, don't answer."}, + { + "role": "assistant", + "content": "", + "reasoning": "Thinking without responding.", + "extra_content": {"anthropic": {"signature": "sig-empty-content"}}, + }, + ] + + _, converted = _convert_messages_for_anthropic(messages) + + assistant_message = converted[1] + assert assistant_message["content"] == [ + {"type": "thinking", "thinking": "Thinking without responding.", "signature": "sig-empty-content"}, + ] + + def test_convert_tool_spec_none_parameters() -> None: """Regression: parameters=None must not raise 'NoneType' object is not subscriptable.""" tools = _convert_tool_spec([{"type": "function", "function": {"name": "ping", "parameters": None}}]) diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 1f90f09e..6ffa253a 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -873,6 +873,70 @@ def test_streaming_completion_with_tool_call() -> None: assert tool_call.function.arguments == '{"location": "Paris"}' +def test_streaming_completion_with_tool_call_preserves_thought_signature() -> None: + """Streaming tool call deltas should carry thought_signature in extra_content, like non-streaming.""" + from any_llm.providers.gemini.utils import _create_openai_chunk_from_google_chunk + + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content = Mock() + + mock_function_call = Mock() + mock_function_call.name = "get_weather" + mock_function_call.args = {"location": "Paris"} + + original_bytes = b"test-signature-bytes" + mock_part = Mock() + mock_part.function_call = mock_function_call + mock_part.thought = None + mock_part.text = None + mock_part.thought_signature = original_bytes + + mock_response.candidates[0].content.parts = [mock_part] + mock_response.candidates[0].finish_reason = Mock() + mock_response.candidates[0].finish_reason.value = "STOP" + mock_response.model_version = "gemini-2.5-flash" + mock_response.usage_metadata = None + + chunk = _create_openai_chunk_from_google_chunk(mock_response) + + assert chunk.choices[0].delta.tool_calls is not None + tool_call = chunk.choices[0].delta.tool_calls[0] + assert tool_call.extra_content is not None + assert tool_call.extra_content["google"]["thought_signature"] == base64.b64encode(original_bytes).decode("utf-8") + + +def test_streaming_completion_with_tool_call_no_thought_signature() -> None: + """Streaming tool call deltas should not set extra_content when no thought_signature is present.""" + from any_llm.providers.gemini.utils import _create_openai_chunk_from_google_chunk + + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content = Mock() + + mock_function_call = Mock() + mock_function_call.name = "get_weather" + mock_function_call.args = {"location": "Paris"} + + mock_part = Mock() + mock_part.function_call = mock_function_call + mock_part.thought = None + mock_part.text = None + mock_part.thought_signature = None + + mock_response.candidates[0].content.parts = [mock_part] + mock_response.candidates[0].finish_reason = Mock() + mock_response.candidates[0].finish_reason.value = "STOP" + mock_response.model_version = "gemini-2.5-flash" + mock_response.usage_metadata = None + + chunk = _create_openai_chunk_from_google_chunk(mock_response) + + assert chunk.choices[0].delta.tool_calls is not None + tool_call = chunk.choices[0].delta.tool_calls[0] + assert tool_call.extra_content is None + + def test_streaming_completion_with_multiple_tool_calls() -> None: """Test that streaming chunks handle multiple parallel tool calls.""" from any_llm.providers.gemini.utils import _create_openai_chunk_from_google_chunk