Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Deep Seek Platform Laravel Package

symfony/ai-deep-seek-platform

DeepSeek platform bridge for Symfony AI, enabling chat completions, function calling, and multi-round conversations through the DeepSeek API. Integrates DeepSeek models into Symfony AI applications.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Understand what you're actually installing. This is not a Laravel package — it's a thin Symfony AI bridge. It contributes DeepSeek to the symfony/ai-platform abstraction (platform factory, model client, model catalog, request/response mapping). Everything you'll do day-to-day happens against Symfony\AI\Platform\*, not against anything DeepSeek-specific. Read the bridge source as documentation; the README here is essentially a link stub.

2. Install with the correct constraint.

composer require symfony/ai-deep-seek-platform symfony/ai-platform symfony/http-client

Pin it — Symfony's BC promise does not apply below 1.0, and this package has already shipped Provider abstraction changes, DeltaInterface replacements, and a TokenUsage shape change inside 0.x:

"symfony/ai-deep-seek-platform": "~0.14.0"

3. Where to look first, in order:

Source Why
vendor/symfony/ai-deep-seek-platform/src/ The PlatformFactory, model catalog, and ModelClient — the real spec
symfony/ai monorepo Tests and sibling bridges (OpenAI, Anthropic, Mistral) are near-identical; they're better docs than this repo
DeepSeek API docs (linked in README) Auth, response_format, function-calling constraints, reasoning_content
symfony/http-client docs You will configure timeouts and inject MockHttpClient in tests

4. Wire it into the container. There is no service provider for you — write one:

// config/services.php  (credentials pattern Laravel expects)
'deepseek' => [
    'key'      => env('DEEPSEEK_API_KEY'),
    'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),
],

// app/Providers/AiServiceProvider.php
public function register(): void
{
    $this->app->singleton(PlatformInterface::class, function ($app) {
        return DeepSeekPlatformFactory::create(
            apiKey:   config('services.deepseek.key'),
            baseUrl:  config('services.deepseek.base_url'),
            httpClient: $app->make(HttpClientInterface::class), // MockHttpClient in tests
        );
    });
}

Register Symfony\Component\HttpClient\HttpClient::create([...]) as HttpClientInterface. Do not try to reuse Laravel's Http facade — the bridge talks to Symfony\Contracts\HttpClient\HttpClientInterface.

5. First use case — a single completion. Get one round trip working before touching tools or streaming:

$platform = app(PlatformInterface::class);
$result   = $platform->invoke('deepseek-chat', 'Summarise this ticket in one sentence: ...');

echo $result->asText();

That's the whole happy path. Everything below is elaboration on it.


Implementation Patterns

Wrap the platform behind your own interface

Given 1 star, 0 dependents, and a 0.x version line, the single highest-value pattern is not letting PlatformInterface leak into your domain code:

interface CompletionsContract
{
    public function complete(string $prompt, array $options = []): string;
    public function stream(string $prompt, callable $onDelta): CompletionResult;
}

Your DeepSeekCompletions implements it today; swapping to OpenAI, Anthropic, or a local model later is a binding change. The Provider abstraction added in v0.8.0 gives you multi-provider routing within Symfony AI, but your app-level seam is what makes the pre-1.0 churn survivable.

Multi-round conversation

Keep the message history in your own DTO/entity — the bridge maps messages, it does not persist them:

$messages = MessageBag::create()
    ->withSystem('You are a support agent for Acme.')
    ->withUser($request->input('message'));

// append prior turns from your Conversation model before invoking
$result = $platform->invoke('deepseek-chat', $messages);

Persist turns in a conversations + messages table. You need them anyway for auditing, and you'll need them to trim context (see Gotchas).

Function calling with a bounded loop

The tool loop is yours to write, and it must be bounded — a model that keeps requesting tools will burn tokens and block the worker:

$toolbox = new Toolbox([new LookupOrderTool(), new RefundOrderTool()]);
$messages = MessageBag::create()->withUser($prompt);

for ($turn = 0; $turn < 5; $turn++) {
    $result = $platform->invoke('deepseek-chat', $messages, ['tools' => $toolbox]);

    if (! $result->hasToolCalls()) {
        return $result->asText();
    }

    $messages = $result->appendToolResults($messages, $toolbox); // execute + feed back
    $this->usage->record($result->getTokenUsage()); // count EVERY round trip
}

throw new ToolLoopExceededException();

Two things that bite here: usage must be recorded per round trip, not per user request, and tool schemas are prompt tokens — every token in every tool description is billed on every turn.

Streaming

DeltaInterface (v0.7.0) gives you semantic chunks instead of raw JSON, and v0.11.0 exposes streamed finish_reason as metadata. Under PHP-FPM this is where naive implementations fail — a 40-second generation sits on a web worker and gets killed by max_execution_time.

Two workable shapes:

SSE through Octane/RoadRunner (lowest latency, requires Octane):

return response()->stream(function () use ($platform, $prompt) {
    $platform->invoke('deepseek-chat', $prompt, ['stream' => true])
        ->each(function (DeltaInterface $delta) {
            echo "data: " . json_encode(['t' => $delta->getContent()]) . "\n\n";
            ob_flush(); flush();
        });
    echo "data: [DONE]\n\n";
}, 200, ['Content-Type' => 'text/event-stream', 'X-Accel-Buffering' => 'no']);

Queue + broadcast (default recommendation): job consumes the stream, accumulates text and TokenUsage, and broadcasts deltas over a private channel via Laravel Echo. Survives client disconnects, retries, and gives you a natural place to commit usage exactly once.

// inside the job, after the stream ends — including on failure
TokenUsageRecorded::dispatch($usage->accumulate($model));

Token usage and cost

v0.14.0 added model to TokenUsage, so a single listener can attribute spend without correlating back to the request payload:

class RecordTokenUsage
{
    public function handle(TokenUsageRecorded $event): void
    {
        UsageEvent::create([
            'model'             => $event->usage->getModel(),
            'prompt_tokens'     => $event->usage->getPromptTokens(),
            'completion_tokens' => $event->usage->getCompletionTokens(),
            'cost_usd'          => $this->rateCard->cost($event->usage),
            'tenant_id'         => $event->tenantId,
        ]);
    }
}

The rate card is yours — the package ships no prices. Put it in config/ai-pricing.php keyed by model ID with an effective_from column, and version it. Prices change; stored costs must be reproducible.

Error handling in Laravel's exception handler

The bridge throws distinct exception types — map them rather than catching \Throwable:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ModelNotFoundException $e) {
        report($e); // almost always a config/catalog drift bug, not a user error
        return response()->json(['error' => 'AI model unavailable'], 503);
    });
    $exceptions->render(function (ExceedContextSizeException $e) {
        return response()->json(['error' => 'Conversation too long'], 422);
    });
    $exceptions->render(function (ServerException $e) {
        return response()->json(['error' => 'Upstream busy, retry'], 503)
            ->header('Retry-After', 5);
    });
})
->withMiddleware(/* ... */);

ServerException is explicitly retryable (v0.11.0) — safe to put behind a queued job's $backoff/$tries.

Fallback across providers

try {
    return $deepSeek->complete($prompt);
} catch (ServerException|ExceedContextSizeException $e) {
    report($e);
    return $openAi->complete($prompt); // same contract, different binding
}

This only works if you've done the interface wrap above. Don't build fallback on PlatformInterface directly — you'll drag Symfony AI types into your callers.

Testing

Never hit the live API in tests. Inject Symfony's MockHttpClient with recorded DeepSeek fixtures and swap the container binding:

$this->app->instance(HttpClientInterface::class, new MockHttpClient([
    new MockResponse(json_encode($fixture), ['response_headers' => ['content-type' => 'application/json']]),
]));

Test three things specifically, because they're where regressions hide: (1) usage accumulation across a multi-round tool conversation, (2) usage committed when a stream aborts mid-flight, (3) your exception mapping for 404/context-limit/5xx.


Gotchas and Tips

Model IDs are configuration, not constants. The catalog is updated by CI (#2141, #2526) and can rename or drop entries on a routine composer update. A wrong ID surfaces as ModelNotFoundException — which looks like an auth or network problem and isn't. Assert your configured IDs against the catalog at boot:

// AppServiceProvider::boot(), non-production: throw. production: log critical.
foreach (config('ai.models') as $id) {
    throw_unless($catalog->has($id), new RuntimeException("Model {$id} missing from catalog"));
}

deepseek-reasoner is not a drop-in for deepseek-chat. Thinking support landed in v0.4.0, and the model emits reasoning_content alongside the answer — it costs tokens you didn't budget for, and DeepSeek's documented constraints differ between the two models (function calling and JSON output in particular). Verify current support against DeepSeek's API docs before assuming a tool-calling workflow will work on the reasoning model.

Token usage on function calling is the undercount trap. Each tool round trip is a separate billed completion with its own TokenUsage. If you record usage only on the final answer, you'll undercount the expensive conversations by 2–5×. Same for streams that end in a client disconnect — accumulate deltas and commit in a finally block.

Streaming under PHP-FPM will bite you. ob_flush() + flush() behind nginx requires X-Accel-Buffering: no and often fastcgi_read_timeout tuning. Long generations also collide with max_execution_time. Default to queue + broadcast unless you're on Octane and have tested the proxy chain.

You are using Symfony HttpClient, not Guzzle. Don't try to shim Laravel's Http facade into HttpClientInterface — configure timeouts on the Symfony client instead (timeout, max_duration, retry_failed). Streaming responses need max_duration, not timeout, or long generations get killed at 30 seconds.

Context size is your problem. ExceedContextSizeException (v0.10.0) is thrown after you've paid for the request attempt. Implement trimming before invoke: drop oldest turns, keep the system prompt, and summarise rather than truncating mid-message.

No pricing table, no cost observability out of the box. TokenUsage::model enables per-request attribution but performs no calculation. Budget a small, permanent maintenance surface: a versioned rate card, currency handling, and periodic reconciliation against DeepSeek's billing export. Expect to find a gap; expect it to be in function-calling round trips.

No retry on ExceedContextSizeException, always retry on ServerException. They're semantically distinct and the bridge is deliberate about it. Retrying a context-size error just burns money.

UTF-8 sanitisation is handled in the client (v0.10.0), but your inputs aren't. Malformed bytes from a PDF extract or a legacy database column will still corrupt prompts in ways that show up as garbled model output, not exceptions. Sanitise at ingestion.

Don't cache config closures that capture the API key. If you resolve the key inside a config:cache-serialised closure, you'll ship a stale credential. Resolve env() → config() → constructor at boot, never at invocation.

Treat every minor bump as a reviewable change. 0.6.0 changed exception behaviour, 0.7.0 replaced streamed chunk types, 0.8.0 introduced the Provider abstraction, 0.11.0 renamed exception semantics. Read the changelog diff and run your mock-fixture tests before merging a Composer update. There is no deprecation layer in 0.x.

Data residency. DeepSeek processes prompts outside your jurisdiction. Persisting UsageEvent rows is safe (tokens and model IDs, no content), but if you store prompt/response pairs for auditing, confirm that's inside your retention and privacy policy before you ship — retrofitting that decision is expensive.

Plan for the integration glue to be yours permanently. With 0 dependents and pre-1.0 versioning, there is no community Laravel wrapper to lean on and no guarantee one appears. The interface-wrapping pattern above is the mitigation: keep the blast radius of a bridge change to a single class.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support