diff --git a/CHANGELOG.md b/CHANGELOG.md index ca9dd7e..f63ff74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,11 @@ This package is used in 100k+ production installs. We take backward compatibilit - No published interface ([`ClientContract`](src/Contracts/ClientContract.php), `ResourceContract`, `ResultContract`, `ApiFactoryContract`) will gain new required methods. New methods on the client implementation will be exposed via separate, additive interfaces. - Default behavior of existing methods will not silently change in ways that affect cost, output, or correctness (e.g. raising default `max_tokens`). -**What's coming in `v2.1.x`:** Every new DeepSeek API feature (V4 models, thinking mode, FIM completion, Anthropic format, user balance, `stop` / `top_p` / `tool_choice` / `logprobs` / `user_id`, chat prefix completion, etc.) will be delivered as **additive** new methods, optional parameters, and enum cases. Bug fixes (`/v3` base URL, ignored params in `chat()` / `code()`, keep-alive line stripping) are also in scope. +**What's been delivered in the `v2.x` line so far:** +- **v2.1.0** — V4 models, base-URL fix, `chat()` / `code()` parameter wiring, keep-alive line stripping. +- **v2.2.0** — thinking mode (`setThinking`, `setReasoningEffort`), `setStop`, `setTopP`, `setToolChoice` (including the previously missing `"required"` mode), `setLogprobs`, `setTopLogprobs`, `setUserId`, OpenAI-spec `name` field on messages, and the `setStrictTool` helper. + +**What's still coming in the `v2.x` line:** FIM completion, real SSE streaming, chat prefix completion (Beta), user balance endpoint, rate-limit handling, response-introspection accessors, and additional DX helpers — all additive. **What's reserved for `v3.0.0`:** Removing deprecated `Models::CODER` / `Models::R1` / `Models::R1Zero`, removing the `Coder` class and `HasCoder` trait, raising default `max_tokens`, retyping `run()` to return a structured DTO, expanding `ClientContract` to match the implementation. All of these will be announced via `@deprecated` notices throughout `v2.x` and shipped with a complete migration guide in [MIGRATION.md](MIGRATION.md). @@ -26,16 +30,12 @@ See [TODO.md](TODO.md) for the full feature gap analysis with per-item BC classi --- -## [Unreleased] - v2.2.0 (planned) +## [Unreleased] - v2.2.x (planned follow-ups) > Additive only. Zero breaking changes from v2.x. See [TODO.md](TODO.md) for the source list. -### Generation parameters and DX -- Thinking mode setters: `setThinking(array $config)` and `setReasoningEffort(string $effort)`. -- New sampling / generation parameter setters: `setStop()`, `setTopP()`, `setToolChoice()`, `setLogprobs()`, `setTopLogprobs()`, `setUserId()`. -- Optional `?string $name` parameter on `query()` and `buildQuery()` for the OpenAI message `name` field. +### Still planned for the v2.2.x line - `setSystemMessage(string $content)` convenience method. -- Tool `strict` mode helper for function definitions. - Response introspection accessors on `SuccessResult`: `getMessage()`, `getUsage()`, `getReasoningContent()`, `getToolCalls()`, `getFinishReason()`, `getCacheHitTokens()` — `run()` continues to return `string`. - `getQueries()`, `getConfig()`, `reset()` introspection helpers. - `DefaultConfigs::MAX_TOKENS` and `DefaultConfigs::RESPONSE_FORMAT_TYPE` cases (`TemperatureValues::MAX_TOKENS` / `RESPONSE_FORMAT_TYPE` deprecated). @@ -54,6 +54,50 @@ See [TODO.md](TODO.md) for the full feature gap analysis with per-item BC classi --- +## [2.2.0] - 2026-05-25 + +> Additive parameter setters. Zero breaking changes from `v2.1.x`. Existing callers who do not opt into any of the new setters receive a byte-identical request body compared to v2.1.x — this is enforced by the regression tests in [`tests/Feature/V220ChangesTest.php`](tests/Feature/V220ChangesTest.php). + +### Added + +#### Generation parameter setters (all optional, all omitted from the request body until explicitly invoked) + +- **Thinking mode** (`TODO.md` #4) — [`setThinking(array $config)`](src/Traits/Client/HasGenerationParams.php) and [`setReasoningEffort(string $effort)`](src/Traits/Client/HasGenerationParams.php). See the [DeepSeek reasoning_model docs](https://api-docs.deepseek.com/guides/reasoning_model). +- **Stop sequences** (`TODO.md` #5) — `setStop(string|array $stop)`. Single strings are normalized to a one-element array. +- **Nucleus sampling** (`TODO.md` #6) — `setTopP(float $topP)`. +- **Tool choice** (`TODO.md` #7) — `setToolChoice(string|array $toolChoice)`. Accepts `"none"`, `"auto"`, `"required"`, or the named-function array shape `['type' => 'function', 'function' => ['name' => '...']]`. The previously missing `"required"` mode is now reachable. +- **Log probabilities** (`TODO.md` #8) — `setLogprobs(bool $enabled)` and `setTopLogprobs(int $count)`. +- **End-user identifier** (`TODO.md` #9) — `setUserId(string $userId)`. Sent on the wire as the OpenAI-spec `user` field. + +#### Message-level additions + +- **OpenAI-spec `name` field on messages** (`TODO.md` #10) — optional 3rd parameter `?string $name = null` added to [`DeepSeekClient::query()`](src/DeepSeekClient.php) and [`DeepSeekClient::buildQuery()`](src/DeepSeekClient.php). The `name` key is omitted entirely from the message when null, preserving the existing 2-argument behavior byte-for-byte. + +#### Function-calling additions + +- **Structured tool `strict` mode helper** (`TODO.md` #13) — new [`setStrictTool(string $name, array $parameters, ?string $description = null)`](src/Traits/Client/HasToolsFunctionCalling.php) appends a function tool with `strict: true` to the existing `tools` array. The existing `setTools(array)` API is unchanged. + +#### Enums and helpers + +- New `QueryFlags` cases: `STOP`, `TOP_P`, `TOOL_CHOICE`, `LOGPROBS`, `TOP_LOGPROBS`, `USER`, `THINKING`, `REASONING_EFFORT`. +- New [`DeepSeek\Enums\Configs\ReasoningEffort`](src/Enums/Configs/ReasoningEffort.php) enum with `HIGH` and `MAX` cases. +- New [`DeepSeek\Enums\Configs\ThinkingType`](src/Enums/Configs/ThinkingType.php) enum with `ENABLED` and `DISABLED` cases. +- New [`DeepSeek\Enums\Queries\ToolChoiceMode`](src/Enums/Queries/ToolChoiceMode.php) enum with `NONE`, `AUTO`, and `REQUIRED` cases. +- New [`DeepSeek\Traits\Client\HasGenerationParams`](src/Traits/Client/HasGenerationParams.php) trait composed into [`DeepSeekClient`](src/DeepSeekClient.php). Holds all eight new setters and their backing nullable state. + +### Internal + +- `run()`, `chat()`, `code()` now merge the new optional parameters via an omit-when-null loop. The original seven request-body keys (`messages`, `model`, `stream`, `temperature`, `max_tokens`, `tools`, `response_format`) remain in their original order and are sent unchanged regardless of whether any new setter is called. This is verified by the three "byte-identical when no new setters called" tests at the top of [`tests/Feature/V220ChangesTest.php`](tests/Feature/V220ChangesTest.php). +- 23 new tests cover BC guards, every new setter, the `name` field, the strict-tool helper, and the new enum surface. The existing test suite (DeepSeekClientTest, V210ChangesTest, FunctionCallingTest) continues to pass unchanged. + +### Backward-compatibility notes + +- No public method, class, trait, enum case, or constant was removed or renamed. +- No existing method signature changed in a breaking way. The new optional `?string $name` parameter on `query()` / `buildQuery()` follows the same additive pattern as the `?string $clientType = null` parameter added to `build()` in v2.1.0. +- `ClientContract` is untouched; the new setters are introduced via the additive `HasGenerationParams` trait on the concrete client. + +--- + ## [2.1.0] - 2026-05-22 > Foundation + Bug Fixes. Zero breaking changes from `v2.0.x`. All deprecated symbols remain fully functional throughout the `v2.x` line. diff --git a/README-AR.md b/README-AR.md index ffa347a..4b35cf3 100644 --- a/README-AR.md +++ b/README-AR.md @@ -32,6 +32,15 @@ - [الاستخدام مع عميل HTTP من Symfony](#الاستخدام-مع-عميل-http-من-symfony) - [الحصول على قائمة النماذج](#الحصول-على-قائمة-النماذج) - [استدعاء الدوال](#استدعاء-الدوال) + - [معلمات التوليد (v2.2.0)](#معلمات-التوليد-v220) + - [وضع التفكير ومستوى الاستدلال](#وضع-التفكير-ومستوى-الاستدلال) + - [تسلسلات الإيقاف (stop)](#تسلسلات-الإيقاف-stop) + - [أخذ العينات بالنواة (top_p)](#أخذ-العينات-بالنواة-top_p) + - [التحكم باختيار الأداة (tool_choice)](#التحكم-باختيار-الأداة-tool_choice) + - [الاحتمالات اللوغاريتمية (logprobs)](#الاحتمالات-اللوغاريتمية-logprobs) + - [معرّف المستخدم النهائي](#معرّف-المستخدم-النهائي) + - [حقل name على الرسائل](#حقل-name-على-الرسائل) + - [أدوات الوضع الصارم (strict)](#أدوات-الوضع-الصارم-strict) - [تكامل مع الأطر](#-تكامل-مع-الأطر) - [🆕 دليل الترحيل](#-دليل-الترحيل) - [📝 سجل التغييرات](#-سجل-التغييرات) @@ -46,11 +55,19 @@ - **تكامل API سلس**: واجهة تعتمد على PHP لميزات الذكاء الاصطناعي في DeepSeek. - **نمط الباني السلس**: أساليب قابلة للسلسلة لبناء الطلبات بطريقة بديهية. - **جاهز للمؤسسات**: تكامل مع عميل HTTP متوافق مع PSR-18. -- **مرونة النماذج**: دعم لعدة نماذج من DeepSeek (Coder, Chat, وغيرها). +- **أحدث نماذج DeepSeek V4**: دعم مباشر لـ `deepseek-v4-pro` و `deepseek-v4-flash` بنافذة سياق تصل إلى مليون رمز ووضعَي التفكير وعدم التفكير. +- **تحكم كامل بمعلمات التوليد (v2.2.0)**: واجهات سلسلة لوضع التفكير، مستوى الاستدلال، تسلسلات الإيقاف، `top_p`، اختيار الأداة (`none` / `auto` / `required` / دالة مسماة)، `logprobs`، معرّف المستخدم النهائي، حقل `name` للرسائل، وأدوات الوضع الصارم. - **جاهز للبث**: دعم مدمج للتعامل مع الردود في الوقت الفعلي. - **العديد من عملاء HTTP**: يمكنك استخدام عميل `Guzzle http client` (افتراضي) أو `symfony http client` بسهولة. - **متوافق مع الأطر**: حزم Laravel و Symfony متاحة. +> **النماذج المدعومة** +> +> - `Models::V4_PRO` — النموذج الرائد، أقصى عدد رموز للإخراج 384K. +> - `Models::V4_FLASH` — سريع وموفّر، أقصى عدد رموز للإخراج 384K. +> +> النماذج القديمة `Models::CHAT`، `Models::CODER`، `Models::R1`، و `Models::R1Zero` مهملة وستتم إزالتها في v3.0.0. الأسماء البديلة `deepseek-chat` و `deepseek-reasoner` ستنسحب من DeepSeek API بتاريخ **2026-07-24**. + --- ## 📦 التثبيت @@ -83,8 +100,10 @@ echo $response; ``` 📌 الإعدادات الافتراضية المستخدمة: -- النموذج: `deepseek-chat` -- الحرارة: 0.8 +- النموذج: الافتراضي من API (لا يُرسل حقل `model` ما لم تستدعِ `withModel()`). +- الحرارة: 1.3 (`TemperatureValues::GENERAL_CONVERSATION`). +- أقصى عدد رموز: 4096. +- صيغة الاستجابة: `text`. ### التكوين المتقدم @@ -92,14 +111,14 @@ echo $response; use DeepSeek\DeepSeekClient; use DeepSeek\Enums\Models; -$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com/v3', timeout:30, clientType:'guzzle'); +$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com', timeout:30, clientType:'guzzle'); $response = $client - ->withModel(Models::CODER->value) + ->withModel(Models::V4_PRO->value) ->withStream() - ->withTemperature(1.2) + ->setTemperature(1.2) ->setMaxTokens(8192) - ->setResponseFormat('text') + ->setResponseFormat('text') // أو "json_object" بحذر. ->query('Explain quantum computing in simple terms') ->run(); @@ -150,7 +169,7 @@ echo 'API Response:'.$response; // مع القيم الافتراضية للـ baseUrl و timeout $client = DeepSeekClient::build('your-api-key', clientType:'symfony') // مع التخصيص -$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com/v3', timeout:30, clientType:'symfony'); +$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com', timeout:30, clientType:'symfony'); $client->query('Explain quantum computing in simple terms') ->run(); @@ -165,7 +184,16 @@ $response = DeepSeekClient::build('your-api-key') ->getModelsList() ->run(); -echo $response; // {"object":"list","data":[{"id":"deepseek-chat","object":"model","owned_by":"deepseek"},{"id":"deepseek-reasoner","object":"model","owned_by":"deepseek"}]} +echo $response; +// { +// "object": "list", +// "data": [ +// {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek"}, +// {"id": "deepseek-v4-flash", "object": "model", "owned_by": "deepseek"}, +// {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"}, // مهمل، ينسحب بتاريخ 2026-07-24 +// {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"} // مهمل، ينسحب بتاريخ 2026-07-24 +// ] +// } ``` ### استدعاء الدوال @@ -176,8 +204,121 @@ echo $response; // {"object":"list","data":[{"id":"deepseek-chat","object":"mode --- -هل ترغب في أن أضع النسخ الثلاث (الإنجليزية + العربية + الصينية) ضمن ملف Markdown موحد؟ +### معلمات التوليد (v2.2.0) + +منذ الإصدار `v2.2.0` تُتيح الحزمة كامل واجهة معلمات التوليد عبر دوال سلسلة. **كل دالة جديدة اختيارية**: إذا لم تستدعِها، يبقى جسم الطلب مطابقًا حرفيًا لإصدار v2.1.x — لا يوجد ما يستوجب الترحيل. + +#### وضع التفكير ومستوى الاستدلال + +تدعم نماذج V4 خطوة "تفكير" مخصصة. استخدم [`setThinking()`](src/Traits/Client/HasGenerationParams.php) لتشغيلها أو إيقافها، و [`setReasoningEffort()`](src/Traits/Client/HasGenerationParams.php) لاختيار `high` (الافتراضي للطلبات العادية) أو `max` (موصى به لمسارات الوكلاء). + +```php +use DeepSeek\DeepSeekClient; +use DeepSeek\Enums\Configs\ReasoningEffort; +use DeepSeek\Enums\Configs\ThinkingType; +use DeepSeek\Enums\Models; + +$response = DeepSeekClient::build('your-api-key') + ->withModel(Models::V4_PRO->value) + ->setThinking(['type' => ThinkingType::ENABLED->value]) + ->setReasoningEffort(ReasoningEffort::MAX->value) + ->query('Prove that there are infinitely many primes.') + ->run(); +``` + +> ⚠️ في وضع التفكير يتجاهل DeepSeek API بصمت كلًّا من `temperature` و `top_p`، كما تُعيد `logprobs` / `top_logprobs` خطأ HTTP 400. انظر [وثائق نموذج الاستدلال](https://api-docs.deepseek.com/guides/reasoning_model). + +#### تسلسلات الإيقاف (stop) + +ما يصل إلى 16 تسلسلًا. مرّر سلسلة نصية واحدة أو مصفوفة؛ السلاسل المفردة تُحوَّل تلقائيًا إلى مصفوفة من عنصر واحد. + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Write a haiku') + ->setStop(['###', "\n\n"]) + ->run(); +``` + +#### أخذ العينات بالنواة (top_p) + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Tell me a short story') + ->setTopP(0.95) + ->run(); +``` + +#### التحكم باختيار الأداة (tool_choice) + +تقبل [`setToolChoice()`](src/Traits/Client/HasGenerationParams.php) القيم `"none"` و `"auto"` و `"required"` (إجبار استدعاء أداة)، أو شكل الدالة المسماة. وضع `"required"` المفقود سابقًا أصبح متاحًا الآن. + +```php +use DeepSeek\Enums\Queries\ToolChoiceMode; + +// إجبار النموذج على استدعاء أي أداة +$client->setTools($tools) + ->setToolChoice(ToolChoiceMode::REQUIRED->value); + +// إجبار النموذج على استدعاء دالة محددة بالاسم +$client->setTools($tools) + ->setToolChoice([ + 'type' => 'function', + 'function' => ['name' => 'get_weather'], + ]); +``` + +#### الاحتمالات اللوغاريتمية (logprobs) + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello') + ->setLogprobs(true) + ->setTopLogprobs(5) + ->run(); +``` + +#### معرّف المستخدم النهائي + +لعزل حدود المعدل، السلامة، وعزل ذاكرة التخزين المؤقت (KV-cache). يُرسل على السلك باسم حقل `user` وفق مواصفة OpenAI. + +```php +$response = DeepSeekClient::build('your-api-key') + ->setUserId('user-42') + ->query('Hello') + ->run(); +``` +#### حقل name على الرسائل + +معامل ثالث اختياري على `query()` (و `buildQuery()`) للتمييز بين المشاركين الذين يحملون نفس الدور وفق مواصفة OpenAI. الاستدعاءات القديمة بمعاملين لا تتأثر. + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello, I am Alice.', 'user', 'alice') + ->query('Hello, I am Bob.', 'user', 'bob') + ->run(); +``` + +#### أدوات الوضع الصارم (strict) + +تُضيف [`setStrictTool()`](src/Traits/Client/HasToolsFunctionCalling.php) دالة بأداة مع `strict: true`، مما يُلزم النموذج بإنتاج وسائط مطابقة تمامًا لمخطط JSON. تتكامل بأمان مع `setTools()` — تُلحِق ولا تستبدل. + +```php +$response = DeepSeekClient::build('your-api-key') + ->setStrictTool( + name: 'get_weather', + parameters: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Get the current weather for a city.', + ) + ->query('What is the weather like in Cairo?') + ->run(); +``` + +--- ### 🛠 تكامل مع الأطر diff --git a/README-CN.md b/README-CN.md index 0641316..78de4e7 100644 --- a/README-CN.md +++ b/README-CN.md @@ -32,6 +32,15 @@ - [使用 Symfony HttpClient](#使用-symfony-httpclient) - [获取模型列表](#获取模型列表) - [函数调用](#函数调用) + - [生成参数(v2.2.0)](#生成参数v220) + - [思考模式与推理强度](#思考模式与推理强度) + - [停止序列(stop)](#停止序列stop) + - [核采样(top_p)](#核采样top_p) + - [工具选择控制(tool_choice)](#工具选择控制tool_choice) + - [对数概率(logprobs)](#对数概率logprobs) + - [最终用户标识](#最终用户标识) + - [消息 name 字段](#消息-name-字段) + - [严格模式工具(strict)](#严格模式工具strict) - [框架集成](#-框架集成) - [🆕 迁移指南](#-迁移指南) - [📝 更新日志](#-更新日志) @@ -46,10 +55,18 @@ - **无缝 API 集成**: DeepSeek AI 功能的 PHP 优先接口 - **构建器模式**: 直观的链接请求构建方法 - **企业级别**: 符合 PSR-18 规范 -- **模型灵活性**: 支持多种 DeepSeek 模型(Coder、Chat 等) +- **最新 DeepSeek V4 模型**: 一流支持 `deepseek-v4-pro` 和 `deepseek-v4-flash`,具备 1M 令牌上下文窗口及思考 / 非思考模式 +- **完整的生成参数控制(v2.2.0)**: 提供思考模式、推理强度、停止序列、`top_p`、工具选择(`none` / `auto` / `required` / 命名函数)、`logprobs`、最终用户标识、消息 `name` 字段以及严格模式工具的链式 setter - **流式传输**: 内置对实时响应处理的支持 - **框架友好**: 提供 Laravel 和 Symfony 包 +> **受支持的模型** +> +> - `Models::V4_PRO` — 旗舰模型,最大输出 384K 令牌。 +> - `Models::V4_FLASH` — 快速、经济,最大输出 384K 令牌。 +> +> 旧版 `Models::CHAT`、`Models::CODER`、`Models::R1` 与 `Models::R1Zero` 已弃用,将在 v3.0.0 中移除。`deepseek-chat` 与 `deepseek-reasoner` 别名将于 **2026-07-24** 从 DeepSeek API 中下线。 + --- ## 📦 安装 @@ -82,23 +99,25 @@ echo $response; ``` 📌 默认配置: -- Model: `deepseek-chat` -- Temperature: 0.8 +- 模型: API 默认(除非调用 `withModel()`,否则不发送 `model` 字段) +- 温度: 1.3(`TemperatureValues::GENERAL_CONVERSATION`) +- 最大令牌数: 4096 +- 响应格式: `text` -### Advanced Configuration +### 高级配置 ```php use DeepSeek\DeepSeekClient; use DeepSeek\Enums\Models; -$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com/v3', timeout:30, clientType:'guzzle'); +$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com', timeout:30, clientType:'guzzle'); $response = $client - ->withModel(Models::CODER->value) + ->withModel(Models::V4_PRO->value) ->withStream() - ->withTemperature(1.2) + ->setTemperature(1.2) ->setMaxTokens(8192) - ->setResponseFormat('text') + ->setResponseFormat('text') // 或 "json_object",请谨慎使用。 ->query('Explain quantum computing in simple terms') ->run(); @@ -139,16 +158,17 @@ echo 'API Response:'.$response; --- -### Use with Symfony HttpClient -the package already built with `symfony Http client`, if you need to use package with `symfony` Http Client , it is easy to achieve that, just pass `clientType:'symfony'` with `build` function. +### 使用 Symfony HttpClient + +本包已内置 `symfony Http client`。若需使用 Symfony 的 HTTP 客户端,只需在 `build` 函数中传入 `clientType:'symfony'` 即可。 -ex with symfony: +Symfony 示例: ```php -// with defaults baseUrl and timeout +// 使用默认的 baseUrl 和 timeout $client = DeepSeekClient::build('your-api-key', clientType:'symfony') -// with customization -$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com/v3', timeout:30, clientType:'symfony'); +// 自定义配置 +$client = DeepSeekClient::build(apiKey:'your-api-key', baseUrl:'https://api.deepseek.com', timeout:30, clientType:'symfony'); $client->query('Explain quantum computing in simple terms') ->run(); @@ -163,7 +183,16 @@ $response = DeepSeekClient::build('your-api-key') ->getModelsList() ->run(); -echo $response; // {"object":"list","data":[{"id":"deepseek-chat","object":"model","owned_by":"deepseek"},{"id":"deepseek-reasoner","object":"model","owned_by":"deepseek"}]} +echo $response; +// { +// "object": "list", +// "data": [ +// {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek"}, +// {"id": "deepseek-v4-flash", "object": "model", "owned_by": "deepseek"}, +// {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"}, // 已弃用,2026-07-24 下线 +// {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"} // 已弃用,2026-07-24 下线 +// ] +// } ``` ### 函数调用 @@ -172,6 +201,123 @@ echo $response; // {"object":"list","data":[{"id":"deepseek-chat","object":"mode 你可以在文档中查看有关函数调用的详细信息: [FUNCTION-CALLING.md](docs/FUNCTION-CALLING.md) +--- + +### 生成参数(v2.2.0) + +从 `v2.2.0` 起,本客户端通过链式 setter 暴露了完整的 DeepSeek 生成参数。**所有新 setter 都是可选的**:若不调用,请求体与 v2.1.x 完全一致 — 无需任何迁移。 + +#### 思考模式与推理强度 + +V4 模型支持专门的"思考"推理阶段。使用 [`setThinking()`](src/Traits/Client/HasGenerationParams.php) 切换开关,使用 [`setReasoningEffort()`](src/Traits/Client/HasGenerationParams.php) 在 `high`(普通请求的默认值)与 `max`(推荐用于 Agent 流程)之间选择。 + +```php +use DeepSeek\DeepSeekClient; +use DeepSeek\Enums\Configs\ReasoningEffort; +use DeepSeek\Enums\Configs\ThinkingType; +use DeepSeek\Enums\Models; + +$response = DeepSeekClient::build('your-api-key') + ->withModel(Models::V4_PRO->value) + ->setThinking(['type' => ThinkingType::ENABLED->value]) + ->setReasoningEffort(ReasoningEffort::MAX->value) + ->query('Prove that there are infinitely many primes.') + ->run(); +``` + +> ⚠️ 在思考模式下,DeepSeek API 会静默忽略 `temperature` / `top_p`,并对 `logprobs` / `top_logprobs` 返回 HTTP 400。请参考 [推理模型文档](https://api-docs.deepseek.com/guides/reasoning_model)。 + +#### 停止序列(stop) + +最多 16 个停止序列。可传入字符串或数组;单个字符串会被规范化为单元素数组。 + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Write a haiku') + ->setStop(['###', "\n\n"]) + ->run(); +``` + +#### 核采样(top_p) + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Tell me a short story') + ->setTopP(0.95) + ->run(); +``` + +#### 工具选择控制(tool_choice) + +[`setToolChoice()`](src/Traits/Client/HasGenerationParams.php) 接受 `"none"`、`"auto"`、`"required"`(强制调用工具)或命名函数数组形式。之前缺失的 `"required"` 模式现在已可用。 + +```php +use DeepSeek\Enums\Queries\ToolChoiceMode; + +// 强制模型调用任意工具 +$client->setTools($tools) + ->setToolChoice(ToolChoiceMode::REQUIRED->value); + +// 强制模型调用指定名称的函数 +$client->setTools($tools) + ->setToolChoice([ + 'type' => 'function', + 'function' => ['name' => 'get_weather'], + ]); +``` + +#### 对数概率(logprobs) + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello') + ->setLogprobs(true) + ->setTopLogprobs(5) + ->run(); +``` + +#### 最终用户标识 + +用于速率限制隔离、内容安全和 KV 缓存隔离。在请求中以 OpenAI 规范的 `user` 字段发送。 + +```php +$response = DeepSeekClient::build('your-api-key') + ->setUserId('user-42') + ->query('Hello') + ->run(); +``` + +#### 消息 name 字段 + +`query()`(与 `buildQuery()`)的可选第 3 个参数,遵循 OpenAI 规范用于区分同一角色下的不同参与者。原有的两参数调用保持不变。 + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello, I am Alice.', 'user', 'alice') + ->query('Hello, I am Bob.', 'user', 'bob') + ->run(); +``` + +#### 严格模式工具(strict) + +[`setStrictTool()`](src/Traits/Client/HasToolsFunctionCalling.php) 追加一个带 `strict: true` 的函数工具,强制模型生成完全符合 JSON Schema 的参数。可与 `setTools()` 安全组合 — 它是追加而非替换。 + +```php +$response = DeepSeekClient::build('your-api-key') + ->setStrictTool( + name: 'get_weather', + parameters: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Get the current weather for a city.', + ) + ->query('What is the weather like in Cairo?') + ->run(); +``` + +--- ### 🛠 框架集成 diff --git a/README.md b/README.md index 8900581..acd29fa 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,15 @@ - [Use with Symfony HttpClient](#use-with-symfony-httpclient) - [Get Models List](#get-models-list) - [Function Calling](#function-calling) + - [Generation Parameters (v2.2.0)](#generation-parameters-v220) + - [Thinking mode and reasoning effort](#thinking-mode-and-reasoning-effort) + - [Stop sequences](#stop-sequences) + - [Nucleus sampling (top_p)](#nucleus-sampling-top_p) + - [Tool choice control](#tool-choice-control) + - [Log probabilities](#log-probabilities) + - [End-user identifier](#end-user-identifier) + - [Message name field](#message-name-field) + - [Strict mode tools](#strict-mode-tools) - [Framework Integration](#-framework-integration) - [🆕 Migration Guide](#-migration-guide) - [📝 Changelog](#-changelog) @@ -47,6 +56,7 @@ - **Fluent Builder Pattern**: Chainable methods for intuitive request building. - **Enterprise Ready**: PSR-18 compliant HTTP client integration. - **Latest DeepSeek V4 Models**: First-class support for `deepseek-v4-pro` and `deepseek-v4-flash` with 1M-token context windows and thinking / non-thinking modes. +- **Full Generation Parameter Control (v2.2.0)**: Fluent setters for thinking mode, reasoning effort, stop sequences, `top_p`, tool choice (`none` / `auto` / `required` / named function), `logprobs`, end-user identifier, message `name` field, and strict-mode tools. - **Streaming Ready**: Built-in support for real-time response handling. - **Many Http Clients**: easy to use `Guzzle http client` (default) , or `symfony http client`. - **Framework Friendly**: Laravel & Symfony packages available. @@ -192,6 +202,123 @@ Function Calling allows the model to call external tools to enhance its capabili You Can check the documentation for function calling in [FUNCTION-CALLING.md](docs/FUNCTION-CALLING.md) +--- + +### Generation Parameters (v2.2.0) + +Since `v2.2.0` the client exposes the full DeepSeek generation surface as fluent setters. **Every new setter is opt-in**: when you don't call it, the request body is byte-identical to v2.1.x — there's nothing to migrate. + +#### Thinking mode and reasoning effort + +V4 models support a dedicated "thinking" reasoning step. Use [`setThinking()`](src/Traits/Client/HasGenerationParams.php) to toggle it on or off, and [`setReasoningEffort()`](src/Traits/Client/HasGenerationParams.php) to choose between `high` (default for normal requests) and `max` (recommended for agent flows). + +```php +use DeepSeek\DeepSeekClient; +use DeepSeek\Enums\Configs\ReasoningEffort; +use DeepSeek\Enums\Configs\ThinkingType; +use DeepSeek\Enums\Models; + +$response = DeepSeekClient::build('your-api-key') + ->withModel(Models::V4_PRO->value) + ->setThinking(['type' => ThinkingType::ENABLED->value]) + ->setReasoningEffort(ReasoningEffort::MAX->value) + ->query('Prove that there are infinitely many primes.') + ->run(); +``` + +> ⚠️ In thinking mode the DeepSeek API silently ignores `temperature` / `top_p`, and `logprobs` / `top_logprobs` return HTTP 400. See the [reasoning model docs](https://api-docs.deepseek.com/guides/reasoning_model). + +#### Stop sequences + +Up to 16 stop sequences. Pass a single string or an array; single strings are normalized to a one-element array. + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Write a haiku') + ->setStop(['###', "\n\n"]) + ->run(); +``` + +#### Nucleus sampling (top_p) + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Tell me a short story') + ->setTopP(0.95) + ->run(); +``` + +#### Tool choice control + +[`setToolChoice()`](src/Traits/Client/HasGenerationParams.php) accepts `"none"`, `"auto"`, `"required"` (force a tool call), or the named-function array shape. The previously missing `"required"` mode is now reachable. + +```php +use DeepSeek\Enums\Queries\ToolChoiceMode; + +// Force the model to call any tool +$client->setTools($tools) + ->setToolChoice(ToolChoiceMode::REQUIRED->value); + +// Force a specific named function +$client->setTools($tools) + ->setToolChoice([ + 'type' => 'function', + 'function' => ['name' => 'get_weather'], + ]); +``` + +#### Log probabilities + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello') + ->setLogprobs(true) + ->setTopLogprobs(5) + ->run(); +``` + +#### End-user identifier + +For rate-limit isolation, content safety, and KV-cache isolation. Sent on the wire as the OpenAI-spec `user` field. + +```php +$response = DeepSeekClient::build('your-api-key') + ->setUserId('user-42') + ->query('Hello') + ->run(); +``` + +#### Message `name` field + +Optional third parameter on `query()` (and `buildQuery()`) to differentiate participants of the same role per OpenAI spec. Existing 2-argument calls are unchanged. + +```php +$response = DeepSeekClient::build('your-api-key') + ->query('Hello, I am Alice.', 'user', 'alice') + ->query('Hello, I am Bob.', 'user', 'bob') + ->run(); +``` + +#### Strict mode tools + +[`setStrictTool()`](src/Traits/Client/HasToolsFunctionCalling.php) appends a function tool with `strict: true`, instructing the model to produce arguments that conform exactly to the JSON schema. Composes safely with `setTools()` — it appends, never replaces. + +```php +$response = DeepSeekClient::build('your-api-key') + ->setStrictTool( + name: 'get_weather', + parameters: [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ], + description: 'Get the current weather for a city.', + ) + ->query('What is the weather like in Cairo?') + ->run(); +``` + +--- ### 🛠 Framework Integration diff --git a/composer.json b/composer.json index 626c9e4..e354fdb 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "role": "creator" } ], - "version": "2.1.0", + "version": "2.2.0", "require": { "php": "^8.2.0", "nyholm/psr7": "^1.8", diff --git a/docs/FUNCTION-CALLING.md b/docs/FUNCTION-CALLING.md index 268ac08..061a229 100644 --- a/docs/FUNCTION-CALLING.md +++ b/docs/FUNCTION-CALLING.md @@ -196,4 +196,69 @@ Output response like :- When using V4 models with thinking mode enabled (or the legacy `DeepSeek-R1`), assistant responses include a `reasoning_content` field at the same level as `content`. **This field MUST be echoed back on the next tool turn**; otherwise the DeepSeek API returns HTTP 400. -See the [DeepSeek reasoning model docs](https://api-docs.deepseek.com/guides/reasoning_model) for details. Helpers for reading `reasoning_content` off the response and passing it back into the next request will land in `v2.2.0` together with `setThinking()` / `setReasoningEffort()`. +Since `v2.2.0` you can toggle thinking mode and reasoning effort directly: + +```php +use DeepSeek\Enums\Configs\ReasoningEffort; +use DeepSeek\Enums\Configs\ThinkingType; + +$client + ->setThinking(['type' => ThinkingType::ENABLED->value]) + ->setReasoningEffort(ReasoningEffort::MAX->value); +``` + +The matching response-side helper for extracting `reasoning_content` and re-injecting it on the next turn is on the `v2.2.x` roadmap (see [CHANGELOG.md](../CHANGELOG.md)). In the meantime, callers should decode the response JSON and pass `reasoning_content` back manually inside the assistant message. + +See the [DeepSeek reasoning model docs](https://api-docs.deepseek.com/guides/reasoning_model) for the full caveat list — notably that `temperature` / `top_p` are silently ignored in thinking mode and `logprobs` / `top_logprobs` return HTTP 400. + +--- + +### Tool choice control (v2.2.0) + +By default DeepSeek decides freely whether to call a tool. Use [`setToolChoice()`](../src/Traits/Client/HasGenerationParams.php) to constrain that behavior: + +| Mode | Effect | +|---|---| +| `'none'` | The model will not call any tool. | +| `'auto'` | The model decides (default behavior). | +| `'required'` | The model MUST call at least one tool. | +| `['type' => 'function', 'function' => ['name' => 'foo']]` | The model MUST call the named function. | + +```php +use DeepSeek\Enums\Queries\ToolChoiceMode; + +// Force the model to call a tool (any tool) +$client->setTools($tools) + ->setToolChoice(ToolChoiceMode::REQUIRED->value); + +// Force the model to call get_weather specifically +$client->setTools($tools) + ->setToolChoice([ + 'type' => 'function', + 'function' => ['name' => 'get_weather'], + ]); +``` + +--- + +### Strict mode tools (v2.2.0) + +`strict: true` on a function definition guarantees the model produces arguments that conform exactly to the JSON schema. Use [`setStrictTool()`](../src/Traits/Client/HasToolsFunctionCalling.php) instead of building the array yourself: + +```php +$client + ->setStrictTool( + name: 'get_weather', + parameters: [ + 'type' => 'object', + 'properties' => [ + 'city' => ['type' => 'string', 'description' => 'The city name'], + ], + 'required' => ['city'], + ], + description: 'Get the current weather in a given city', + ) + ->query('What is the weather like in Cairo?'); +``` + +`setStrictTool()` **appends** to whatever `setTools()` already set; it never replaces. You can mix strict and non-strict tools in the same request by chaining calls. diff --git a/src/DeepSeekClient.php b/src/DeepSeekClient.php index 3d55b4e..1dbedf3 100644 --- a/src/DeepSeekClient.php +++ b/src/DeepSeekClient.php @@ -11,6 +11,7 @@ use DeepSeek\Enums\Requests\QueryFlags; use DeepSeek\Factories\ApiFactory; use DeepSeek\Resources\Resource; +use DeepSeek\Traits\Client\HasGenerationParams; use DeepSeek\Traits\Client\HasToolsFunctionCalling; use DeepSeek\Traits\Resources\HasChat; use DeepSeek\Traits\Resources\HasCoder; @@ -19,6 +20,7 @@ class DeepSeekClient implements ClientContract { use HasChat, HasCoder; + use HasGenerationParams; use HasToolsFunctionCalling; /** @@ -93,6 +95,12 @@ public function run(): string ], ]; + foreach ($this->getOptionalRequestParams() as $key => $value) { + if ($value !== null) { + $requestData[$key] = $value; + } + } + $this->setResult((new Resource($this->httpClient, $this->endpointSuffixes))->sendRequest($requestData, $this->requestMethod)); return $this->getResult()->getContent(); @@ -122,11 +130,14 @@ public static function build(string $apiKey, ?string $baseUrl = null, ?int $time /** * Add a query to the accumulated queries list. * + * @param string|null $name Optional OpenAI-spec "name" field on the message. + * When non-null it differentiates participants of the + * same role. Omitted from the message when null. * @return self The current instance for method chaining. */ - public function query(string $content, ?string $role = 'user'): self + public function query(string $content, ?string $role = 'user', ?string $name = null): self { - $this->queries[] = $this->buildQuery($content, $role); + $this->queries[] = $this->buildQuery($content, $role, $name); return $this; } @@ -203,12 +214,18 @@ public function setResponseFormat(string $type): self return $this; } - public function buildQuery(string $content, ?string $role = null): array + public function buildQuery(string $content, ?string $role = null, ?string $name = null): array { - return [ + $query = [ 'role' => $role ?: QueryRoles::USER->value, 'content' => $content, ]; + + if ($name !== null) { + $query['name'] = $name; + } + + return $query; } /** diff --git a/src/Enums/Configs/ReasoningEffort.php b/src/Enums/Configs/ReasoningEffort.php new file mode 100644 index 0000000..4e09808 --- /dev/null +++ b/src/Enums/Configs/ReasoningEffort.php @@ -0,0 +1,17 @@ + 'function', 'function' => ['name' => 'my_function']] + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ +enum ToolChoiceMode: string +{ + case NONE = 'none'; + case AUTO = 'auto'; + case REQUIRED = 'required'; +} diff --git a/src/Enums/Requests/QueryFlags.php b/src/Enums/Requests/QueryFlags.php index 29e2586..f421fe3 100644 --- a/src/Enums/Requests/QueryFlags.php +++ b/src/Enums/Requests/QueryFlags.php @@ -11,4 +11,60 @@ enum QueryFlags: string case MAX_TOKENS = 'max_tokens'; case TOOLS = 'tools'; case RESPONSE_FORMAT = 'response_format'; + + /** + * Up to 16 stop sequences (string or string[]). + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ + case STOP = 'stop'; + + /** + * Nucleus sampling parameter (0..1). Alternative to temperature. + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ + case TOP_P = 'top_p'; + + /** + * Tool choice control: "none" | "auto" | "required" | {"type": "function", "function": {"name": "..."}}. + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ + case TOOL_CHOICE = 'tool_choice'; + + /** + * Whether to return log probabilities of the output tokens. + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ + case LOGPROBS = 'logprobs'; + + /** + * Number of most likely tokens to return at each token position with log probabilities. + * + * @see https://api-docs.deepseek.com/api/create-chat-completion + */ + case TOP_LOGPROBS = 'top_logprobs'; + + /** + * End-user identifier for rate-limit isolation, content safety, and KV-cache isolation. + * + * @see https://api-docs.deepseek.com/quick_start/rate_limit + */ + case USER = 'user'; + + /** + * Thinking mode configuration: ["type" => "enabled" | "disabled"]. + * + * @see https://api-docs.deepseek.com/guides/reasoning_model + */ + case THINKING = 'thinking'; + + /** + * Reasoning effort: "high" | "max". + * + * @see https://api-docs.deepseek.com/guides/reasoning_model + */ + case REASONING_EFFORT = 'reasoning_effort'; } diff --git a/src/Traits/Client/HasGenerationParams.php b/src/Traits/Client/HasGenerationParams.php new file mode 100644 index 0000000..f1e66cd --- /dev/null +++ b/src/Traits/Client/HasGenerationParams.php @@ -0,0 +1,185 @@ +|null + */ + protected string|array|null $toolChoice = null; + + /** + * Whether the API should return log probabilities for output tokens. + */ + protected ?bool $logprobs = null; + + /** + * Number of most-likely tokens to include in the logprobs response. + */ + protected ?int $topLogprobs = null; + + /** + * End-user identifier. Sent as the OpenAI-spec "user" field. + */ + protected ?string $userId = null; + + /** + * Thinking-mode configuration array, e.g. ["type" => "enabled"]. + * + * @var array|null + */ + protected ?array $thinking = null; + + /** + * Reasoning effort level ("high" | "max"). + */ + protected ?string $reasoningEffort = null; + + /** + * Set the stop sequences. Accepts a single string or an array of strings. + * + * @param string|array $stop Up to 16 stop sequences. + * @return self The current instance for method chaining. + */ + public function setStop(string|array $stop): self + { + $this->stop = is_string($stop) ? [$stop] : array_values($stop); + + return $this; + } + + /** + * Set the top_p nucleus-sampling parameter. + * + * @return self The current instance for method chaining. + */ + public function setTopP(float $topP): self + { + $this->topP = $topP; + + return $this; + } + + /** + * Set the OpenAI-style tool_choice value. + * + * @param string|array $toolChoice "none" | "auto" | "required" + * or ["type" => "function", "function" => ["name" => "..."]]. + * @return self The current instance for method chaining. + */ + public function setToolChoice(string|array $toolChoice): self + { + $this->toolChoice = $toolChoice; + + return $this; + } + + /** + * Enable or disable token log probabilities in the API response. + * + * @return self The current instance for method chaining. + */ + public function setLogprobs(bool $enabled): self + { + $this->logprobs = $enabled; + + return $this; + } + + /** + * Set the number of most-likely tokens to include with log probabilities. + * + * @return self The current instance for method chaining. + */ + public function setTopLogprobs(int $count): self + { + $this->topLogprobs = $count; + + return $this; + } + + /** + * Set the end-user identifier sent as the OpenAI-spec "user" field. + * + * @return self The current instance for method chaining. + */ + public function setUserId(string $userId): self + { + $this->userId = $userId; + + return $this; + } + + /** + * Set the thinking-mode configuration array. + * + * @param array $config Typically ["type" => "enabled"] or ["type" => "disabled"]. + * @return self The current instance for method chaining. + */ + public function setThinking(array $config): self + { + $this->thinking = $config; + + return $this; + } + + /** + * Set the reasoning effort level ("high" | "max"). + * + * @return self The current instance for method chaining. + */ + public function setReasoningEffort(string $effort): self + { + $this->reasoningEffort = $effort; + + return $this; + } + + /** + * Return the optional v2.2.0 parameters keyed by API field name. + * + * Callers MUST filter out null values before merging into the request + * body — that is what preserves byte-identical request payloads for + * users who have not opted into any new setter. + * + * @return array + */ + protected function getOptionalRequestParams(): array + { + return [ + QueryFlags::STOP->value => $this->stop, + QueryFlags::TOP_P->value => $this->topP, + QueryFlags::TOOL_CHOICE->value => $this->toolChoice, + QueryFlags::LOGPROBS->value => $this->logprobs, + QueryFlags::TOP_LOGPROBS->value => $this->topLogprobs, + QueryFlags::USER->value => $this->userId, + QueryFlags::THINKING->value => $this->thinking, + QueryFlags::REASONING_EFFORT->value => $this->reasoningEffort, + ]; + } +} diff --git a/src/Traits/Client/HasToolsFunctionCalling.php b/src/Traits/Client/HasToolsFunctionCalling.php index 9cae45b..d06b950 100644 --- a/src/Traits/Client/HasToolsFunctionCalling.php +++ b/src/Traits/Client/HasToolsFunctionCalling.php @@ -17,6 +17,43 @@ public function setTools(array $tools): self return $this; } + /** + * Append a single function tool with structured-output strict mode enabled. + * + * Strict mode is a DeepSeek Beta feature: when enabled the model is + * guaranteed to produce tool-call arguments that conform exactly to the + * provided JSON schema. See the DeepSeek API reference for caveats. + * + * This helper is purely additive — it appends to whatever the existing + * setTools() / prior setStrictTool() calls have already stored, so it + * never silently replaces an existing tool list. + * + * @param string $name Function name. + * @param array $parameters JSON-schema object describing the function arguments. + * @param string|null $description Optional human-readable description. + * @return self The current instance for method chaining. + */ + public function setStrictTool(string $name, array $parameters, ?string $description = null): self + { + $this->tools = $this->tools ?? []; + + $function = ['name' => $name]; + + if ($description !== null) { + $function['description'] = $description; + } + + $function['parameters'] = $parameters; + $function['strict'] = true; + + $this->tools[] = [ + 'type' => 'function', + 'function' => $function, + ]; + + return $this; + } + /** * Add a query tool calls to the accumulated queries list. * diff --git a/src/Traits/Resources/HasChat.php b/src/Traits/Resources/HasChat.php index b03063e..2a83fb8 100644 --- a/src/Traits/Resources/HasChat.php +++ b/src/Traits/Resources/HasChat.php @@ -27,6 +27,13 @@ public function chat(): string 'type' => $this->responseFormatType, ], ]; + + foreach ($this->getOptionalRequestParams() as $key => $value) { + if ($value !== null) { + $requestData[$key] = $value; + } + } + $this->queries = []; $this->setResult((new Chat($this->httpClient))->sendRequest($requestData)); diff --git a/src/Traits/Resources/HasCoder.php b/src/Traits/Resources/HasCoder.php index 4ef5d52..e8a8df8 100644 --- a/src/Traits/Resources/HasCoder.php +++ b/src/Traits/Resources/HasCoder.php @@ -27,6 +27,13 @@ public function code(): string 'type' => $this->responseFormatType, ], ]; + + foreach ($this->getOptionalRequestParams() as $key => $value) { + if ($value !== null) { + $requestData[$key] = $value; + } + } + $this->queries = []; $this->setResult((new Coder($this->httpClient))->sendRequest($requestData)); diff --git a/tests/Feature/V220ChangesTest.php b/tests/Feature/V220ChangesTest.php new file mode 100644 index 0000000..9aeb8ba --- /dev/null +++ b/tests/Feature/V220ChangesTest.php @@ -0,0 +1,469 @@ +shouldReceive('sendRequest') + ->once() + ->andReturnUsing(function (RequestInterface $request) use ($factory, &$capturedBody): ResponseInterface { + $capturedBody = (string) $request->getBody(); + + return $factory->createResponse(200)->withBody($factory->createStream('{"id":"ok"}')); + }); + + return $httpClient; +} + +// ===================================================================== +// Backward-compatibility guards (these MUST pass first) +// ===================================================================== + +test('run() request body shape is byte-identical when no new setters called', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_FLASH->value) + ->query('Hello') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toBeArray() + ->and(array_keys($decoded)) + ->toBe(['messages', 'model', 'stream', 'temperature', 'max_tokens', 'tools', 'response_format']); +}); + +test('chat() shortcut request body shape is byte-identical when no new setters called', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_FLASH->value) + ->query('Hello') + ->chat(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toBeArray() + ->and(array_keys($decoded)) + ->toBe(['messages', 'model', 'stream', 'temperature', 'max_tokens', 'tools', 'response_format']); +}); + +test('code() shortcut request body shape is byte-identical when no new setters called', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_PRO->value) + ->query('def fib(n):') + ->code(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toBeArray() + ->and(array_keys($decoded)) + ->toBe(['messages', 'model', 'stream', 'temperature', 'max_tokens', 'tools', 'response_format']); +}); + +// ===================================================================== +// Per-setter additive tests +// ===================================================================== + +test('setStop(string) adds stop as a single-element array', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setStop('###') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toHaveKey('stop') + ->and($decoded['stop'])->toBe(['###']); +}); + +test('setStop(array) adds stop as passed', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setStop(['###', '', "\n\n"]) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['stop'])->toBe(['###', '', "\n\n"]); +}); + +test('setTopP adds top_p to request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setTopP(0.95) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['top_p'])->toBe(0.95); +}); + +test('setToolChoice with string "auto" adds tool_choice', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setToolChoice('auto') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tool_choice'])->toBe('auto'); +}); + +test('setToolChoice with named function array adds tool_choice', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + $named = ['type' => 'function', 'function' => ['name' => 'get_weather']]; + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setToolChoice($named) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tool_choice'])->toBe($named); +}); + +test('setToolChoice with "required" reaches the wire', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setToolChoice(ToolChoiceMode::REQUIRED->value) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tool_choice'])->toBe('required'); +}); + +test('setLogprobs(true) adds logprobs to request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setLogprobs(true) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toHaveKey('logprobs') + ->and($decoded['logprobs'])->toBeTrue(); +}); + +test('setTopLogprobs adds top_logprobs to request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setTopLogprobs(5) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['top_logprobs'])->toBe(5); +}); + +test('setUserId adds OpenAI-spec user field', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setUserId('user-42') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['user'])->toBe('user-42'); +}); + +test('setThinking adds thinking config to request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setThinking(['type' => ThinkingType::ENABLED->value]) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['thinking'])->toBe(['type' => 'enabled']); +}); + +test('setReasoningEffort adds reasoning_effort to request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setReasoningEffort(ReasoningEffort::MAX->value) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['reasoning_effort'])->toBe('max'); +}); + +test('all new setters combined produce all new keys plus all existing keys', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_PRO->value) + ->query('Hello') + ->setStop(['###']) + ->setTopP(0.9) + ->setToolChoice('auto') + ->setLogprobs(true) + ->setTopLogprobs(3) + ->setUserId('user-42') + ->setThinking(['type' => 'enabled']) + ->setReasoningEffort('high') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toHaveKeys([ + 'messages', 'model', 'stream', 'temperature', 'max_tokens', 'tools', 'response_format', + 'stop', 'top_p', 'tool_choice', 'logprobs', 'top_logprobs', 'user', 'thinking', 'reasoning_effort', + ]) + ->and($decoded['model'])->toBe('deepseek-v4-pro'); +}); + +// ===================================================================== +// name field on messages (#10) +// ===================================================================== + +test('query() with no name argument produces a message with only role and content', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('hi') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['messages'])->toBeArray()->toHaveCount(1) + ->and(array_keys($decoded['messages'][0]))->toBe(['role', 'content']) + ->and($decoded['messages'][0])->toBe(['role' => 'user', 'content' => 'hi']); +}); + +test('query() with name argument emits the name field', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('hi', 'user', 'alice') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['messages'][0]) + ->toBe(['role' => 'user', 'content' => 'hi', 'name' => 'alice']); +}); + +test('buildQuery() with name argument returns the name key', function () { + $client = new DeepSeekClient(Mockery::mock(ClientInterface::class)); + + $result = $client->buildQuery('hi', null, 'bob'); + + expect($result) + ->toBe(['role' => 'user', 'content' => 'hi', 'name' => 'bob']); +}); + +// ===================================================================== +// Tool strict mode helper (#13) +// ===================================================================== + +test('setStrictTool adds a function tool with strict=true', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + $schema = [ + 'type' => 'object', + 'properties' => ['city' => ['type' => 'string']], + 'required' => ['city'], + ]; + + (new DeepSeekClient($httpClient)) + ->query('Weather?') + ->setStrictTool('get_weather', $schema, 'Get the current weather for a city.') + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tools'])->toBeArray()->toHaveCount(1) + ->and($decoded['tools'][0]) + ->toBe([ + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'description' => 'Get the current weather for a city.', + 'parameters' => $schema, + 'strict' => true, + ], + ]); +}); + +test('setStrictTool called twice appends both tools', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setStrictTool('foo', ['type' => 'object']) + ->setStrictTool('bar', ['type' => 'object']) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tools'])->toHaveCount(2) + ->and($decoded['tools'][0]['function']['name'])->toBe('foo') + ->and($decoded['tools'][1]['function']['name'])->toBe('bar'); +}); + +test('setTools followed by setStrictTool appends without dropping existing tools', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + $preset = [[ + 'type' => 'function', + 'function' => ['name' => 'preset_tool', 'description' => 'preset'], + ]]; + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setTools($preset) + ->setStrictTool('strict_tool', ['type' => 'object']) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded['tools'])->toHaveCount(2) + ->and($decoded['tools'][0]['function']['name'])->toBe('preset_tool') + ->and($decoded['tools'][1]['function']['name'])->toBe('strict_tool') + ->and($decoded['tools'][1]['function']['strict'])->toBeTrue(); +}); + +test('setStrictTool omits the description key when not provided', function () { + $factory = new Psr17Factory; + $capturedBody = null; + $httpClient = v220MockClient($factory, $capturedBody); + + (new DeepSeekClient($httpClient)) + ->query('Hello') + ->setStrictTool('noop', ['type' => 'object']) + ->run(); + + $decoded = json_decode((string) $capturedBody, true); + + expect(array_keys($decoded['tools'][0]['function'])) + ->toBe(['name', 'parameters', 'strict']); +}); + +// ===================================================================== +// Enum surface +// ===================================================================== + +test('new QueryFlags cases exist with the expected scalar values', function () { + expect(QueryFlags::STOP->value)->toBe('stop') + ->and(QueryFlags::TOP_P->value)->toBe('top_p') + ->and(QueryFlags::TOOL_CHOICE->value)->toBe('tool_choice') + ->and(QueryFlags::LOGPROBS->value)->toBe('logprobs') + ->and(QueryFlags::TOP_LOGPROBS->value)->toBe('top_logprobs') + ->and(QueryFlags::USER->value)->toBe('user') + ->and(QueryFlags::THINKING->value)->toBe('thinking') + ->and(QueryFlags::REASONING_EFFORT->value)->toBe('reasoning_effort'); +}); + +test('ReasoningEffort enum exposes "high" and "max"', function () { + expect(ReasoningEffort::HIGH->value)->toBe('high') + ->and(ReasoningEffort::MAX->value)->toBe('max'); +}); + +test('ThinkingType enum exposes "enabled" and "disabled"', function () { + expect(ThinkingType::ENABLED->value)->toBe('enabled') + ->and(ThinkingType::DISABLED->value)->toBe('disabled'); +}); + +test('ToolChoiceMode enum exposes "none", "auto", and "required"', function () { + expect(ToolChoiceMode::NONE->value)->toBe('none') + ->and(ToolChoiceMode::AUTO->value)->toBe('auto') + ->and(ToolChoiceMode::REQUIRED->value)->toBe('required'); +});