Search by

adachsoft / ai-integration

Arkadiusz Adach

Unified AI tool-calling chat abstraction for PHP 8.3 with pluggable SPI providers (OpenAI, Deepseek) and a clean Public API.

Package info

gitlab.com/a.adach/AiIntegration

Issues

pkg:composer/adachsoft/ai-integration

Statistics

Installs: 185

Dependents: 13

Suggesters: 0

Stars: 0

v0.10.0 2026-11-11 22:36 UTC

README

Unified AI tool-calling chat abstraction for PHP 8.3 with pluggable providers and a framework-agnostic Public API.

  • Public API for chat requests and provider selection.
  • Built-in OpenAI and DeepSeek integrations.
  • A small SPI for custom providers and optional request-cost calculation.
  • Optional HTTP traffic logging and no framework-container requirement.

Requirements and installation

  • PHP 8.3 or later
  • ext-json and ext-mbstring
composer require adachsoft/ai-integration

The conversation messages, content parts, tools, usage, and cost use the shared adachsoft/ai-conversation-model types.

Quick start

use AdachSoft\AiConversationModel\Content\Collection\ContentPartCollection;
use AdachSoft\AiConversationModel\Content\TextContentPartDto;
use AdachSoft\AiConversationModel\Message\ChatMessageDto;
use AdachSoft\AiConversationModel\Message\ChatMessageIdVo;
use AdachSoft\AiConversationModel\Message\ChatRoleEnum;
use AdachSoft\AiConversationModel\Message\Collection\ChatMessageDtoCollection;
use AdachSoft\AiConversationModel\Tool\Collection\ToolCallDtoCollection;
use AdachSoft\AiConversationModel\Tool\Collection\ToolDefinitionDtoCollection;
use AdachSoft\AiConversationModel\Tool\Collection\ToolResultDtoCollection;
use AdachSoft\AiConversationModel\Tool\ToolDefinitionDto;
use AdachSoft\AiConversationModel\Tool\ToolNameVo;
use AdachSoft\AiIntegration\Contract\ToolCalling\Dto\ChatChoiceDto;
use AdachSoft\AiIntegration\PublicApi\Builder\ToolCallingChatFacadeBuilder;
use AdachSoft\AiIntegration\PublicApi\ToolCalling\Dto\ToolCallingChatRequestDto;

$createdAt = new DateTimeImmutable('2026-01-01T00:00:00+00:00');
$messages = new ChatMessageDtoCollection([
    new ChatMessageDto(
        id: new ChatMessageIdVo('00000000-0000-4000-8000-000000000001'),
        createdAt: $createdAt,
        role: ChatRoleEnum::System,
        content: new ContentPartCollection([
            new TextContentPartDto('You are a helpful assistant.'),
        ]),
        toolCalls: new ToolCallDtoCollection([]),
        toolResults: new ToolResultDtoCollection([]),
        metadata: [],
    ),
    new ChatMessageDto(
        id: new ChatMessageIdVo('00000000-0000-4000-8000-000000000002'),
        createdAt: $createdAt->modify('+1 second'),
        role: ChatRoleEnum::User,
        content: new ContentPartCollection([
            new TextContentPartDto('Add 2 and 3 using the sum tool.'),
        ]),
        toolCalls: new ToolCallDtoCollection([]),
        toolResults: new ToolResultDtoCollection([]),
        metadata: [],
    ),
]);

$tools = new ToolDefinitionDtoCollection([
    new ToolDefinitionDto(
        name: new ToolNameVo('sum'),
        description: 'Adds two numbers.',
        parametersSchema: [
            'type' => 'object',
            'properties' => [
                'a' => ['type' => 'number'],
                'b' => ['type' => 'number'],
            ],
            'required' => ['a', 'b'],
            'additionalProperties' => false,
        ],
    ),
]);

$facade = ToolCallingChatFacadeBuilder::create()
    ->withOpenAi(apiKey: getenv('OPENAI_API_KEY'))
    ->build();

$completion = $facade->chat(new ToolCallingChatRequestDto(
    messages: $messages,
    tools: $tools,
    providerId: 'openai',
    parameters: ['temperature' => 0.1],
    metadata: [],
    modelId: 'gpt-4o-mini',
    timeoutSeconds: 30,
));

// A completion may contain multiple independent choices. Select one explicitly.
$choice = null;
foreach ($completion->choices as $candidate) {
    $choice = $candidate;
    break;
}

if (!$choice instanceof ChatChoiceDto) {
    throw new RuntimeException('The provider returned no choices.');
}

// Append the selected assistant turn before any tool results.
$messagesForNextRequest = iterator_to_array($messages);
$messagesForNextRequest[] = new ChatMessageDto(
    id: new ChatMessageIdVo('00000000-0000-4000-8000-000000000003'),
    createdAt: $createdAt->modify('+2 seconds'),
    role: ChatRoleEnum::Assistant,
    content: $choice->content,
    toolCalls: $choice->toolCalls,
    toolResults: new ToolResultDtoCollection([]),
    metadata: $choice->metadata,
);

// Create one ToolResultDto for every call in the selected choice.
$results = [];
foreach ($choice->toolCalls as $call) {
    // Execute the requested tool with $call->arguments and use its actual result.
    $results[] = new \AdachSoft\AiConversationModel\Tool\ToolResultDto(
        toolCallId: $call->id,
        toolName: $call->name,
        content: new ContentPartCollection([
            new TextContentPartDto(json_encode(['result' => 5], JSON_THROW_ON_ERROR)),
        ]),
    );
}

if ($results !== []) {
    $messagesForNextRequest[] = ChatMessageDto::toolResults(
        new ChatMessageIdVo('00000000-0000-4000-8000-000000000004'),
        $createdAt->modify('+3 seconds'),
        new ToolResultDtoCollection($results),
    );
}

$nextRequest = new ToolCallingChatRequestDto(
    messages: new ChatMessageDtoCollection($messagesForNextRequest),
    tools: $tools,
    providerId: 'openai',
    parameters: ['temperature' => 0.1],
    metadata: [],
    modelId: 'gpt-4o-mini',
);

$nextCompletion = $facade->chat($nextRequest);

ToolCallingChatRequestDto takes messages, tools, providerId, parameters, and metadata, followed by optional modelId and timeoutSeconds. The shared message DTO requires an ID and creation time; keep IDs unique and timestamps in conversation order. A choice is not automatically added to history: the caller selects it and appends its assistant message. Each non-empty ToolResultDto contains the matching call ID, tool name, and content parts.

Supported media

Use shared MediaContentPartDto content parts. detail is supplied as a named media parameter where supported.

ContentRole and providerSupported source and details
Images (image/jpeg, image/png, image/gif, image/webp)User, OpenAIBase64 or URL; optional detail is sent as image_url.detail.
ImagesUser, DeepSeekBase64 or URL. DeepSeek also accepts an image FileId; the MIME type may be omitted for a file ID.
PDF (application/pdf)User, OpenAIBase64 with a filename, or FileId; URL sources are not supported.
Input audio (audio/wav, audio/mpeg)User, OpenAIBase64; these map to the provider's WAV and MP3 input-audio formats.
Generated audioAssistant, OpenAIThe assistant media part refers to the provider audio object through parameters['provider_audio_id'].

OpenAI-compatible chat-completions tool results cannot carry media as tool-message content. For a tool result containing media, add an additional user message after the assistant message and all tool results. Use one such user message per media-bearing result; the library does not turn this into native tool-result media support.

For DeepSeek reasoning, select the relevant choice and preserve its metadata['openai_compatible_message_fields']['reasoning_content'] when creating the assistant history message. Passing the selected choice's metadata through unchanged preserves this field.

Extending OpenAI-compatible content handling

The built-in encoders are composed through ContentPartEncoderCollection and implement ContentPartEncoderInterface. Supply extra encoders through the extraEncoders parameter of the OpenAI or DeepSeek builder methods; extra encoders are checked before the defaults. MessageFormatterInterface isolates provider-specific role and message formatting.

OpenAiCompatibleToolCallingChatSpiFactory::create() also accepts optional extraEncoders and messageFormatter arguments. A null formatter selects the standard OpenAI-compatible formatter. Unsupported or conflicting content is rejected with UnsupportedContentException before an HTTP request is sent.

Request cost

Cost calculation is optional and has no built-in pricing tables. CostCalculatorInterface::calculate(CostCalculationRequestDto $request) returns ?AdachSoft\AiConversationModel\Cost\CostDto. The request's $tokenUsage is the shared AdachSoft\AiConversationModel\Usage\TokenUsageDto.

The calculator is called once for the whole completion. Without a calculator, the provider's cost is preserved when present. With a calculator, a returned cost replaces the provider cost; returning null preserves the provider cost. See the cost-calculation SPI guide.

Provider usage details are available in $tokenUsage->details. The parser preserves provider-specific nested values rather than flattening them into aliases. For example, inspect the prompt_tokens_details.cached_tokens value inside the corresponding entry in details; see the guide for handling its preserved shape.

Providers, logging, and CLI demonstrator

  • OpenAI (openai): https://api.openai.com/v1/chat/completions; set OPENAI_API_KEY.
  • DeepSeek (deepseek): https://api.deepseek.com/chat/completions; set DEEPSEEK_API_KEY.

A CLI demonstrator runs a smoke request and two tool-calling scenarios:

php bin/test-ai-tool-chat.php --provider=openai --model=gpt-4o-mini --show-meta=on --log-http=1 --log-headers=0
php bin/test-ai-tool-chat.php --provider=deepseek --model=deepseek-flash

HTTP traffic logging is optional and can be enabled with --log-http=1. Request headers are hidden unless --log-headers=1 is also supplied.

Limitations

  • Streaming is not supported.
  • Chat Completions does not generate images and does not accept video input.
  • deepseek-v4-pro does not support vision.
  • deepseek-chat has been withdrawn; use deepseek-flash for DeepSeek chat and vision requests.
  • The package does not implement production file upload or the Responses API.

SPI documentation

External integrations should use the Public API to call the model and the SPI to implement providers. The reusable OpenAI-compatible toolkit is documented in the tool-calling SPI guide. The package also documents cost calculation and HTTP traffic logging.

Migration from the previous model

The following previous type names are listed only to identify migration targets.

Previous type or fieldCurrent type or field
PublicApi\ToolCalling\Dto\ChatMessageDtoAiConversationModel\Message\ChatMessageDto
PublicApi\ToolCalling\Dto\ChatRoleEnumAiConversationModel\Message\ChatRoleEnum
PublicApi\ToolCalling\Dto\ToolCallDtoAiConversationModel\Tool\ToolCallDto
PublicApi\ToolCalling\Dto\ToolDefinitionDtoAiConversationModel\Tool\ToolDefinitionDto
PublicApi\ToolCalling\Dto\TokenUsageDtoAiConversationModel\Usage\TokenUsageDto
PublicApi\ToolCalling\Dto\CostDtoAiConversationModel\Cost\CostDto
PublicApi\ToolCalling\Dto\ToolCallingChatResponseDtoContract\ToolCalling\Dto\ChatCompletionDto with choices
Public message, call, and definition collectionsShared ChatMessageDtoCollection, ToolCallDtoCollection, and ToolDefinitionDtoCollection
Domain\Model\ChatMessage, ChatRoleEnum, ToolCall, ToolDefinition, TokenUsage, ChatResponseShared message, tool, usage, and ChatCompletionDto types
Domain\Model\ToolCallingChatPromptDomain\Model\ToolCallingChatPromptDto
Domain message, call, and definition collectionsShared ChatMessageDtoCollection, ToolCallDtoCollection, and ToolDefinitionDtoCollection
Spi\ToolCalling\Dto\ChatMessageSpiDto, ToolCallSpiDto, ToolDefinitionSpiDto, TokenUsageSpiDtoShared message, tool, and TokenUsageDto types
Spi\ToolCalling\Dto\ToolCallingChatSpiRequestSpi\ToolCalling\Dto\ToolCallingChatSpiRequestDto
Spi\ToolCalling\Dto\ToolCallingChatSpiResponseContract\ToolCalling\Dto\ChatCompletionDto
SPI message, call, and definition collectionsShared message, tool, and definition collections
Spi\Cost\Dto\CostSpiDtoAiConversationModel\Cost\CostDto
Support\OpenAiCompatible\Dto\ChatMessage, ToolCall, ToolDefinition, TokenUsage, ChatResult, ToolCallingChatRequestShared message, tool, usage, and completion types; ToolCallingChatRequestDto remains the Support request DTO
Public, Domain, and Support response mappersRemoved; pass the shared model through the flow
TokenUsageDto::$details access previously based on raw_usage or the cached_input_tokens aliasRead provider usage fields from the shared TokenUsageDto::$details; nested fields retain their original shape

Testing

Run the two configured non-production suites independently:

vendor/bin/phpunit --testsuite Unit
vendor/bin/phpunit --testsuite Integration