Problem/Motivation

To support native Anthropic API features (effort, thinking, caching, compaction), we need the official PHP SDK installed and a Drupal service wrapping it. This is the foundation that all subsequent
feature issues in the [META] Native Anthropic SDK integration depend on.

The SDK provides two approaches for making API calls:

  • Typed methods ($client->messages->create()): For features with full SDK support (prompt caching, structured outputs, tool cache_control)
  • Raw requests ($client->request()): The SDK's recommended escape hatch for features not yet typed (adaptive thinking, effort). Same auth, retries, and error handling.

Both paths use the same SDK infrastructure. As the SDK adds types for more features, raw request calls migrate to typed methods with no API changes.

Steps to reproduce

N/A — new functionality.

Proposed resolution

  1. Add anthropic-ai/sdk: ^0.5 and php-http/guzzle7-adapter to composer.json
  2. Create AnthropicNativeClient service (src/Service/AnthropicNativeClient.php) wrapping the SDK with Drupal's HTTP client
  3. Register the service in ai_provider_anthropic.services.yml
  4. Override chat() in AnthropicProvider with hybrid routing: detect when native features are configured → route to nativeChat(), otherwise fall through to
    parent::chat()
  5. Basic nativeChat() implementation using $client->request()

Key constraint: SDK v0.5.0 has a known issue where extraBodyParams is defined in RequestOptions but never merged into requests. The
$client->request() method is the reliable alternative.

No new features in this issue — just the plumbing. Effort, thinking, streaming, and token usage are handled in follow-up issues.

Remaining tasks

  • Add composer dependencies
  • Create AnthropicNativeClient service
  • Create services.yml
  • Override chat() with hybrid routing
  • Verify basic chat still works via compat layer (no regression)

User interface changes

None.

API changes

None. Internal routing only.

Data model changes

None.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

camoa created an issue. See original summary.

camoa’s picture

Foundation: Native SDK client and hybrid chat() routing

Branch 3572139-add-anthropic-aisdk-dependency adds the anthropic-ai/sdk PHP package and creates the plumbing for native API features in future issues.

Changes

  • composer.json: Added anthropic-ai/sdk: ^0.5 and php-http/guzzle7-adapter: ^1.0
  • AnthropicNativeClient service: SDK wrapper using $client->request() escape hatch for untyped params (effort, thinking not yet typed in SDK v0.5)
  • AnthropicProvider: chat() override with requiresNativeApi() gate, nativeChat(), buildNativePayload(),
    parseNativeResponse()
  • handleApiException(): Anthropic-specific credit balance check, delegates to parent for rate-limit handling
  • Token usage: parseNativeResponse() maps input_tokens, output_tokens, and cache_read_input_tokens to TokenUsageDto
  • 18 unit tests (36 assertions): routing logic, payload building, response parsing, native client wiring

Design

  • requiresNativeApi() currently returns FALSE — no native-only features are wired yet
  • All basic chat continues through the OpenAI compat layer via parent::chat()
  • Phase 1 (effort, thinking) will add config checks to requiresNativeApi() AND wire them in buildNativePayload() together
  • No phpunit.xml shipped — CI uses core's phpunit.xml.dist via GitLab templates

Verification

  • phpcs (Drupal + DrupalPractice): clean
  • phpstan level 1: clean
  • PHPUnit: 18 tests, 36 assertions passing
camoa’s picture

Status: Active » Needs review
camoa’s picture

Assigned: camoa » Unassigned
camoa’s picture

How native API routing works

The routing decision is config-driven via requiresNativeApi().

In this Foundation MR, it always returns FALSE — no native-only features are wired yet, so all requests continue through the OpenAI compat layer with zero change in behavior.

Phase 1 will change it to check configuration keys:

  protected function requiresNativeApi(): bool {
      $config = $this->configuration;
      return !empty($config['effort'])
          || !empty($config['thinking_mode']);
  }
  

These config keys come from getModelSettings(), which adds UI fields (effort dropdown, thinking mode selector) to the per-model settings form. The Drupal AI framework passes them back as
$this->configuration on each request.

Request flow

  • No native features configuredparent::chat() via OpenAI compat (proven path, no regression)
  • Effort or thinking enablednativeChat() via Anthropic SDK

This means the site admin controls the routing per model. If they don't enable any native-only features, everything stays on the compat path. The native SDK path only activates when explicitly
needed.

Each Phase 1 feature added must update both requiresNativeApi() (the check) and buildNativePayload() (the implementation) to prevent mismatches.

camoa’s picture

Symfony AI forward compatibility

This work is designed with the Drupal AI → Symfony AI migration in mind (AI 2.0 Autumn 2026, AI 3.0 Early 2027).

The Symfony AI Anthropic bridge already uses the native Messages API (not OpenAI compat), making our approach
directionally aligned.

How our code maps to Symfony AI

  • buildNativePayload()Contract::createRequestPayload() + MessageBagNormalizer
  • parseNativeResponse()ResultConverter::convert() + TokenUsageExtractor::extract()
  • AnthropicNativeClient::requestRaw()ModelClient::request()

Portability of Phase 1 features

Symfony AI merges arbitrary options into the API payload via array_merge($options, $payload). This means:

  • Effort — passes through as $options['output_config']['effort'] with zero Symfony AI changes
  • Adaptive thinking — passes through as $options['thinking'] with zero Symfony AI changes
  • Thinking response parsing — requires ResultConverter update (upstream contribution opportunity)
  • Thinking token usage — requires TokenUsageExtractor update (upstream contribution opportunity)

Design choices for portability

  • Plain arrays for payloads and responses (no SDK-specific type abstractions)
  • Each feature in buildNativePayload() is a clearly-delimited block, easy to extract into Symfony AI options
  • Token extraction separated from text parsing, mirroring Symfony AI's split architecture

Phase 1–2 features provide immediate value for the current module and inform upstream contributions to symfony/ai-anthropic-platform when the migration happens.

camoa’s picture

Version: 1.2.x-dev » 1.3.x-dev
michael.romero’s picture

Hi team,

I’ve been reviewing the code and noticed that the Anthropic SDK is currently defined as:


"anthropic-ai/sdk": "^0.5"

Using a caret (^) constraint may introduce compatibility risks over time, as future minor releases from Anthropic could include changes that affect the current implementation. To ensure long-term stability and predictable behavior, I would recommend locking the dependency to a specific version instead of allowing automatic minor upgrades.

Additionally, while reviewing the AnthropicProvider class, I observed that it does not define a constructor. Without a constructor, dependency injections cannot be stored in class properties and accessed via $this->..., which limits extensibility and maintainability. Introducing a constructor would allow proper assignment of injected services to properties.

It would also be beneficial to define nativeClient as protected readonly, which would improve immutability and clearly communicate that the client should not be modified after instantiation.

camoa’s picture

Thanks for the review, Michael!

1. Version constraint (^0.5): Keeping as-is. For pre-1.0 packages, Composer's caret already constrains to >=0.5.0, <0.6.0 — only patch updates are allowed. This follows standard Drupal contrib practice and
avoids the maintenance burden of exact pins on downstream consumers.

2. Constructor: The grandparent AiProviderClientBase::__construct() is final, so we can't override it. The create() factory + property assignment is the only available DI pattern here.

3. Readonly: Good call — applying protected readonly to nativeClient. It works with the create() pattern since PHP allows readonly initialization from the declaring class scope.

  • camoa committed 73174326 on 1.3.x
    Issue #3572139: Add anthropic-ai/sdk dependency and native client...
camoa’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.