diff --git a/.github/workflows/deepseek-automation-test.yml b/.github/workflows/deepseek-automation-test.yml index 2b78312..6a10da8 100644 --- a/.github/workflows/deepseek-automation-test.yml +++ b/.github/workflows/deepseek-automation-test.yml @@ -18,7 +18,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: ['8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4'] steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index b1df40d..9bb0c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,11 @@ vendor tests/TestSupport/temp tests/temp composer.lock -phpunit.xml .env .phpunit.cache/ .phpunit.result.cache .phpunit.cache/test-results .php-cs-fixer.cache -phpstan.neon tests/Support/temp/ .idea/ AGENTS.md @@ -17,7 +15,6 @@ AGENTS.md /.php-cs-fixer.cache /.php-cs-fixer.php /composer.lock -/phpunit.xml /vendor/ *.swp *.swo diff --git a/CHANGELOG.md b/CHANGELOG.md index b9682fa..ca9dd7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,129 @@ # Changelog -All notable changes to `deepseek-php-client` will be documented in this file +All notable changes to `deepseek-php-client` are documented in this file. -## 1.0.0 - 201X-XX-XX +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- initial release +--- + +## Backward Compatibility Commitment + +This package is used in 100k+ production installs. We take backward compatibility seriously and follow semantic versioning strictly within the `v2.x` line. + +**Our commitments for the entire `v2.x` line:** + +- No public method, class, trait, enum case, or constant will be **removed** or **renamed**. +- No public method's **return type** will change. +- No public method's existing **parameters** will change type or be removed (new optional parameters with defaults may be added). +- 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 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). + +See [TODO.md](TODO.md) for the full feature gap analysis with per-item BC classification. + +--- + +## [Unreleased] - v2.2.0 (planned) + +> 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. +- `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). +- Additive `ExtendedClientContract` interface (preserves the existing `ClientContract`). + +## [Unreleased] - v2.3.0 (planned) + +- Real streaming via new `runStreamed(callable $onChunk): void` method (existing `->withStream()->run()` string-returning behavior preserved). +- `stream_options.include_usage` exposure. +- Chat Prefix Completion (Beta): `queryAssistantPrefix(string $content)` and `/beta` base URL opt-in. +- `getUserBalance()` method, `EndpointSuffixes::USER_BALANCE` enum case, and `UserBalanceResult` DTO. +- FIM Completion (Beta): new resource class for `POST /completions` (Fill-In-the-Middle). +- Rate-limit handling: new `RateLimitResult` class with parsed `Retry-After` header for HTTP 429 responses. + +> Note: Anthropic API format support is intentionally not on the v2.x roadmap. It may be reintroduced in a later release if there is sufficient community demand. + +--- + +## [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. + +### Added +- New `Models::V4_PRO` (`deepseek-v4-pro`) and `Models::V4_FLASH` (`deepseek-v4-flash`) enum cases. Both models support DeepSeek's 1M-token context window and dual thinking / non-thinking modes (per the [V4 Preview announcement](https://api-docs.deepseek.com/news/news260424)). + +### Fixed +- **Default `baseUrl` corrected** from `https://api.deepseek.com/v3` to `https://api.deepseek.com`. The `/v3` path was never a valid DeepSeek API endpoint. Users who passed an explicit `baseUrl` to `DeepSeekClient::build()` are unaffected. +- **`chat()` and `code()` shortcuts** now honor `temperature`, `maxTokens`, `tools`, and `responseFormat` set on the client. Previously these settings were silently dropped from the request body when using the shortcut methods; the request body now matches what `run()` sends. +- **Keep-Alive padding stripped from responses.** The DeepSeek API may send empty lines (non-streaming) or `: keep-alive` SSE comments (streaming) while waiting for inference to start. These are now removed before the response content is exposed to user code. See the [DeepSeek Rate Limit docs](https://api-docs.deepseek.com/quick_start/rate_limit#request-keep-alive-mechanism) for details. + +### Deprecated +The following symbols are deprecated and will be removed in `v3.0.0`. They remain fully functional throughout the `v2.x` line — only IDE `@deprecated` notices are emitted (no `trigger_error()`). + +- `Models::CHAT` — the `deepseek-chat` alias retires from the DeepSeek API on 2026-07-24. Use `Models::V4_FLASH` (with `setThinking(['type' => 'disabled'])` in v2.2.0+ for non-thinking mode). +- `Models::CODER` — `deepseek-coder` no longer exists in the DeepSeek API. Use `Models::V4_PRO` or `Models::V4_FLASH`. +- `Models::R1` — the `DeepSeek-R1` alias retires from the DeepSeek API on 2026-07-24. Use `Models::V4_FLASH` (with `setThinking(['type' => 'enabled'])` in v2.2.0+ for thinking mode). +- `Models::R1Zero` — `DeepSeek-R1-Zero` was never a valid DeepSeek API model id. + +### Documentation +- README refreshed: default temperature corrected (1.3, not 0.8); `baseUrl` example updated; advanced-configuration example now uses `Models::V4_PRO`; `getModelsList()` example output updated to include V4 models; new "Supported Models" callout under Features. +- `docs/FUNCTION-CALLING.md` updated: JSON examples use `deepseek-v4-pro`; new "Thinking-mode caveat" section explaining that `reasoning_content` must be echoed back on tool turns to avoid HTTP 400 from the API. + +### Internal +- New test file: `tests/Feature/V210ChangesTest.php` covering V4 enum cases, default base URL, Keep-Alive stripping (both non-streaming and streaming), and `chat()` / `code()` request body completeness. + +--- + +## [2.0.6] - 2025 + +Current published baseline prior to v2.1.0. No breaking changes from `2.0.0`. + +Patch-level fixes and documentation updates rolled up since `2.0.0`. This release marks the point from which the [Backward Compatibility Commitment](#backward-compatibility-commitment) above applies. + +--- + +## [2.0.0] - 2025-02-01 + +### Changed (Breaking) +- **Namespace renamed** from `DeepseekPhp` to `DeepSeek`. All imports in user code must be updated. See [MIGRATION.md](MIGRATION.md) for details. + + Replace: + ```php + use DeepseekPhp\SomeClass; + ``` + With: + ```php + use DeepSeek\SomeClass; + ``` + +--- + +## [Roadmap] - v3.0.0 (no ETA) + +> Breaking changes only. Will ship with a complete migration guide in [MIGRATION.md](MIGRATION.md). Users will get at least one full `v2.1.x` release with `@deprecated` notices before any of these land. + +### Removed +- Deprecated `Models` enum cases: `CHAT`, `CODER`, `R1`, `R1Zero` (per the 2026-07-24 DeepSeek API retirement of the `deepseek-chat` and `deepseek-reasoner` aliases). +- `Resources\Coder` class and `Traits\Resources\HasCoder` trait (including `code()`). +- `Enums\Configs\TemperatureValues::MAX_TOKENS` and `RESPONSE_FORMAT_TYPE` cases. + +### Changed (Breaking) +- `run()` will return a structured `ChatCompletionResult` DTO instead of a raw JSON `string`. The current string-returning behavior moves to a new method (`runRaw()` or equivalent). +- Default `MAX_TOKENS` raised from 4096 to a V4-appropriate value (V4 models support 384K output tokens — current default is ~1% of capacity). +- `ClientContract` expanded to declare all methods the implementation provides: `resetQueries()`, `setTemperature()`, `setMaxTokens()`, `setResponseFormat()`, `setResult()`, `getResult()`, and the 4-arg `build()` signature including `?string $clientType`. +- Default `baseUrl` no longer accepts the legacy `/v3` suffix (already removed in `v2.1.x` as a fix; v3.0.0 removes the back-compat shim). + +--- + +## [1.0.0] - 201X-XX-XX + +- Initial release. diff --git a/README.md b/README.md index 1209d00..8900581 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,18 @@ - **Seamless API Integration**: PHP-first interface for DeepSeek's AI capabilities. - **Fluent Builder Pattern**: Chainable methods for intuitive request building. - **Enterprise Ready**: PSR-18 compliant HTTP client integration. -- **Model Flexibility**: Support for multiple DeepSeek models (Coder, Chat, etc.). +- **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. - **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. +> **Supported Models** +> +> - `Models::V4_PRO` — flagship 1.6T/49B-active model, max 384K output tokens. +> - `Models::V4_FLASH` — fast, economical 284B/13B-active model, max 384K output tokens. +> +> Legacy `Models::CHAT`, `Models::CODER`, `Models::R1`, and `Models::R1Zero` are deprecated and will be removed in v3.0.0. The `deepseek-chat` and `deepseek-reasoner` aliases retire from the DeepSeek API on **2026-07-24**. + --- ## 📦 Installation @@ -83,8 +90,10 @@ echo $response; ``` 📌 Defaults used: -- Model: `deepseek-chat` -- Temperature: 0.8 +- Model: API default (no `model` field sent unless you call `withModel()`) +- Temperature: 1.3 (`TemperatureValues::GENERAL_CONVERSATION`) +- Max tokens: 4096 +- Response format: `text` ### Advanced Configuration @@ -92,10 +101,10 @@ 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() ->setTemperature(1.2) ->setMaxTokens(8192) @@ -149,7 +158,7 @@ ex with symfony: // with defaults baseUrl and 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(); @@ -164,7 +173,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"}, // deprecated, retires 2026-07-24 +// {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"} // deprecated, retires 2026-07-24 +// ] +// } ``` diff --git a/composer.json b/composer.json index e9e0a67..626c9e4 100644 --- a/composer.json +++ b/composer.json @@ -49,9 +49,9 @@ "role": "creator" } ], - "version": "2.0.6", + "version": "2.1.0", "require": { - "php": "^8.1.0", + "php": "^8.2.0", "nyholm/psr7": "^1.8", "php-http/discovery": "^1.20.0", "php-http/multipart-stream-builder": "^1.4.2", diff --git a/docs/FUNCTION-CALLING.md b/docs/FUNCTION-CALLING.md index 4d115cf..268ac08 100644 --- a/docs/FUNCTION-CALLING.md +++ b/docs/FUNCTION-CALLING.md @@ -53,7 +53,7 @@ Output response like. "id": "chat_12345", "object": "chat.completion", "created": 1677654321, - "model": "deepseek-chat", + "model": "deepseek-v4-pro", "choices": [ { "index": 0, @@ -136,7 +136,7 @@ Request like "content": "{\"temperature\":22,\"condition\":\"Sunny\"}" } ], - "model": "deepseek-chat", + "model": "deepseek-v4-pro", "stream": false, "temperature": 1.3, "tools": [ @@ -176,7 +176,7 @@ Output response like :- "id": "chat_67890", "object": "chat.completion", "created": 1677654322, - "model": "deepseek-chat", + "model": "deepseek-v4-pro", "choices": [ { "index": 0, @@ -190,3 +190,10 @@ Output response like :- } ``` +--- + +### Thinking-mode caveat + +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()`. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..91e6453 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 5 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7051518 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./src + + + diff --git a/src/Contracts/ClientContract.php b/src/Contracts/ClientContract.php index 12172e3..c865a8a 100644 --- a/src/Contracts/ClientContract.php +++ b/src/Contracts/ClientContract.php @@ -5,10 +5,16 @@ interface ClientContract { public function run(): string; + public static function build(string $apiKey, ?string $baseUrl = null, ?int $timeout = null): self; - public function query(string $content, ?string $role = "user"): self; + + public function query(string $content, ?string $role = 'user'): self; + public function getModelsList(): self; + public function withModel(?string $model = null): self; + public function withStream(bool $stream = true): self; + public function buildQuery(string $content, ?string $role = null): array; } diff --git a/src/Contracts/Factories/ApiFactoryContract.php b/src/Contracts/Factories/ApiFactoryContract.php index 7799e7e..646f48a 100644 --- a/src/Contracts/Factories/ApiFactoryContract.php +++ b/src/Contracts/Factories/ApiFactoryContract.php @@ -10,39 +10,32 @@ interface ApiFactoryContract { /** * Create a new instance of the factory. - * - * @return ApiFactory */ public static function build(): ApiFactory; /** * Set the base URL for the API. * - * @param string|null $baseUrl The base URL to set (optional). - * @return ApiFactory + * @param string|null $baseUrl The base URL to set (optional). */ public function setBaseUri(?string $baseUrl = null): ApiFactory; /** * Set the API key for authentication. * - * @param string $apiKey The API key to set. - * @return ApiFactory + * @param string $apiKey The API key to set. */ public function setKey(string $apiKey): ApiFactory; /** * Set the timeout for the API request. * - * @param int|null $timeout The timeout value in seconds (optional). - * @return ApiFactory + * @param int|null $timeout The timeout value in seconds (optional). */ public function setTimeout(?int $timeout = null): ApiFactory; /** * Build and return http Client instance. - * - * @return ClientInterface */ public function run(?string $clientType = null): ClientInterface; } diff --git a/src/Contracts/Models/ResultContract.php b/src/Contracts/Models/ResultContract.php index 764c6f6..5c1b249 100644 --- a/src/Contracts/Models/ResultContract.php +++ b/src/Contracts/Models/ResultContract.php @@ -6,19 +6,16 @@ interface ResultContract { /** * result status code - * @return int */ public function getStatusCode(): int; /** * result content date as a string - * @return string */ public function getContent(): string; /** * if response status code is ok (200) - * @return bool */ public function isSuccess(): bool; } diff --git a/src/Contracts/Resources/ResourceContract.php b/src/Contracts/Resources/ResourceContract.php index 9fc174c..2315767 100644 --- a/src/Contracts/Resources/ResourceContract.php +++ b/src/Contracts/Resources/ResourceContract.php @@ -9,22 +9,16 @@ interface ResourceContract { /** * Get the endpoint suffix for the resource. - * - * @return string */ public function getEndpointSuffix(): string; /** * Get the model associated with the resource. - * - * @return string */ public function getDefaultModel(): string; /** * check if stream enabled or not. - * - * @return bool */ public function getDefaultStream(): bool; } diff --git a/src/DeepSeekClient.php b/src/DeepSeekClient.php index 62e39f5..3d55b4e 100644 --- a/src/DeepSeekClient.php +++ b/src/DeepSeekClient.php @@ -4,16 +4,17 @@ use DeepSeek\Contracts\ClientContract; use DeepSeek\Contracts\Models\ResultContract; +use DeepSeek\Enums\Configs\TemperatureValues; +use DeepSeek\Enums\Queries\QueryRoles; use DeepSeek\Enums\Requests\ClientTypes; use DeepSeek\Enums\Requests\EndpointSuffixes; -use DeepSeek\Resources\Resource; -use Psr\Http\Client\ClientInterface; -use DeepSeek\Factories\ApiFactory; -use DeepSeek\Enums\Queries\QueryRoles; use DeepSeek\Enums\Requests\QueryFlags; -use DeepSeek\Enums\Configs\TemperatureValues; -use DeepSeek\Traits\Resources\{HasChat, HasCoder}; +use DeepSeek\Factories\ApiFactory; +use DeepSeek\Resources\Resource; use DeepSeek\Traits\Client\HasToolsFunctionCalling; +use DeepSeek\Traits\Resources\HasChat; +use DeepSeek\Traits\Resources\HasCoder; +use Psr\Http\Client\ClientInterface; class DeepSeekClient implements ClientContract { @@ -22,39 +23,32 @@ class DeepSeekClient implements ClientContract /** * PSR-18 HTTP client for making requests. - * - * @var ClientInterface */ protected ClientInterface $httpClient; /** * Array to store accumulated queries. - * - * @var array */ protected array $queries = []; /** * The model being used for API requests. - * - * @var string|null */ protected ?string $model; /** * Indicates whether to enable streaming for API responses. - * - * @var bool */ protected bool $stream; protected float $temperature; + protected int $maxTokens; + protected string $responseFormatType; /** * response result contract - * @var ResultContract */ protected ResultContract $result; @@ -64,14 +58,13 @@ class DeepSeekClient implements ClientContract /** * Array of tools for using function calling. - * @var array|null $tools */ protected ?array $tools; /** * Initialize the DeepSeekClient with a PSR-compliant HTTP client. * - * @param ClientInterface $httpClient The HTTP client used for making API requests. + * @param ClientInterface $httpClient The HTTP client used for making API requests. */ public function __construct(ClientInterface $httpClient) { @@ -90,26 +83,27 @@ public function run(): string { $requestData = [ QueryFlags::MESSAGES->value => $this->queries, - QueryFlags::MODEL->value => $this->model, - QueryFlags::STREAM->value => $this->stream, - QueryFlags::TEMPERATURE->value => $this->temperature, - QueryFlags::MAX_TOKENS->value => $this->maxTokens, - QueryFlags::TOOLS->value => $this->tools, - QueryFlags::RESPONSE_FORMAT->value => [ - 'type' => $this->responseFormatType + QueryFlags::MODEL->value => $this->model, + QueryFlags::STREAM->value => $this->stream, + QueryFlags::TEMPERATURE->value => $this->temperature, + QueryFlags::MAX_TOKENS->value => $this->maxTokens, + QueryFlags::TOOLS->value => $this->tools, + QueryFlags::RESPONSE_FORMAT->value => [ + 'type' => $this->responseFormatType, ], ]; $this->setResult((new Resource($this->httpClient, $this->endpointSuffixes))->sendRequest($requestData, $this->requestMethod)); + return $this->getResult()->getContent(); } /** * Create a new DeepSeekClient instance with the given API key. * - * @param string $apiKey The API key for authentication. - * @param string|null $baseUrl The base URL for the API (optional). - * @param int|null $timeout The timeout duration for requests in seconds (optional). + * @param string $apiKey The API key for authentication. + * @param string|null $baseUrl The base URL for the API (optional). + * @param int|null $timeout The timeout duration for requests in seconds (optional). * @return self A new instance of the DeepSeekClient. */ public static function build(string $apiKey, ?string $baseUrl = null, ?int $timeout = null, ?string $clientType = null): self @@ -128,24 +122,24 @@ public static function build(string $apiKey, ?string $baseUrl = null, ?int $time /** * Add a query to the accumulated queries list. * - * @param string $content - * @param string|null $role * @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'): self { $this->queries[] = $this->buildQuery($content, $role); + return $this; } - + /** * Reset a queries list to empty. * * @return self The current instance for method chaining. */ - public function resetQueries() + public function resetQueries(): self { $this->queries = []; + return $this; } @@ -158,48 +152,54 @@ public function getModelsList(): self { $this->endpointSuffixes = EndpointSuffixes::MODELS_LIST->value; $this->requestMethod = 'GET'; + return $this; } /** * Set the model to be used for API requests. * - * @param string|null $model The model name (optional). + * @param string|null $model The model name (optional). * @return self The current instance for method chaining. */ public function withModel(?string $model = null): self { $this->model = $model; + return $this; } /** * Enable or disable streaming for API responses. * - * @param bool $stream Whether to enable streaming (default: true). + * @param bool $stream Whether to enable streaming (default: true). * @return self The current instance for method chaining. */ public function withStream(bool $stream = true): self { $this->stream = $stream; + return $this; } public function setTemperature(float $temperature): self { $this->temperature = $temperature; + return $this; } public function setMaxTokens(int $maxTokens): self { $this->maxTokens = $maxTokens; + return $this; } public function setResponseFormat(string $type): self { $this->responseFormatType = $type; + return $this; } @@ -207,24 +207,24 @@ public function buildQuery(string $content, ?string $role = null): array { return [ 'role' => $role ?: QueryRoles::USER->value, - 'content' => $content + 'content' => $content, ]; } /** * set result model - * @param \DeepSeek\Contracts\Models\ResultContract $result + * * @return self The current instance for method chaining. */ - public function setResult(ResultContract $result) + public function setResult(ResultContract $result): self { $this->result = $result; + return $this; } /** * response result model - * @return \DeepSeek\Contracts\Models\ResultContract */ public function getResult(): ResultContract { diff --git a/src/Enums/Configs/DefaultConfigs.php b/src/Enums/Configs/DefaultConfigs.php index 204c9d1..cd4a907 100644 --- a/src/Enums/Configs/DefaultConfigs.php +++ b/src/Enums/Configs/DefaultConfigs.php @@ -4,8 +4,8 @@ enum DefaultConfigs: string { - case BASE_URL = 'https://api.deepseek.com/v3'; - case MODEL = 'DeepSeek-R1'; + case BASE_URL = 'https://api.deepseek.com'; + case MODEL = 'deepseek-v4-flash'; case TIMEOUT = '30'; case STREAM = 'false'; } diff --git a/src/Enums/Configs/TemperatureValues.php b/src/Enums/Configs/TemperatureValues.php index c09d315..5ad9210 100644 --- a/src/Enums/Configs/TemperatureValues.php +++ b/src/Enums/Configs/TemperatureValues.php @@ -4,14 +4,45 @@ enum TemperatureValues: string { - case CODING = "0.0"; - case MATH = "0.1"; - case DATA_ANALYSIS = "1.0"; - case DATA_CLEANING = "1.1"; - case GENERAL_CONVERSATION = "1.3"; - case TRANSLATION = "1.4"; - case CREATIVE_WRITING = "1.5"; - case POETRY = "1.6"; - case MAX_TOKENS = "4096"; - case RESPONSE_FORMAT_TYPE = "text"; + /** Recommended by DeepSeek docs: 0.0 for Coding / Math. */ + case CODING = '0.0'; + + /** + * @deprecated Use CODING instead. DeepSeek docs recommend the same temperature (0.0) + * for both Coding and Math. Kept for backward compatibility. + * @see https://api-docs.deepseek.com/quick_start/parameter_settings + */ + case MATH = '0.1'; + + /** Recommended by DeepSeek docs: 1.0 for Data Cleaning / Data Analysis. */ + case DATA_ANALYSIS = '1.0'; + + /** + * @deprecated Use DATA_ANALYSIS instead. DeepSeek docs recommend the same temperature (1.0) + * for both Data Analysis and Data Cleaning. Kept for backward compatibility. + * @see https://api-docs.deepseek.com/quick_start/parameter_settings + */ + case DATA_CLEANING = '1.1'; + + /** Recommended by DeepSeek docs: 1.3 for General Conversation / Translation. */ + case GENERAL_CONVERSATION = '1.3'; + + /** + * @deprecated Use GENERAL_CONVERSATION instead. DeepSeek docs recommend the same temperature (1.3) + * for both General Conversation and Translation. Kept for backward compatibility. + * @see https://api-docs.deepseek.com/quick_start/parameter_settings + */ + case TRANSLATION = '1.4'; + + /** Recommended by DeepSeek docs: 1.5 for Creative Writing / Poetry. */ + case CREATIVE_WRITING = '1.5'; + + /** + * @deprecated Use CREATIVE_WRITING instead. DeepSeek docs recommend the same temperature (1.5) + * for both Creative Writing and Poetry. Kept for backward compatibility. + * @see https://api-docs.deepseek.com/quick_start/parameter_settings + */ + case POETRY = '1.6'; + case MAX_TOKENS = '4096'; + case RESPONSE_FORMAT_TYPE = 'text'; } diff --git a/src/Enums/Models.php b/src/Enums/Models.php index ff699ed..4cbcec3 100644 --- a/src/Enums/Models.php +++ b/src/Enums/Models.php @@ -4,8 +4,56 @@ enum Models: string { + /** + * @deprecated since 2.1.0, will be removed in 3.0.0. + * The 'deepseek-chat' alias retires from the DeepSeek API on 2026-07-24 + * (currently routes to deepseek-v4-flash non-thinking mode). + * Use Models::V4_FLASH or Models::V4_PRO instead. + */ case CHAT = 'deepseek-chat'; + + /** + * @deprecated since 2.1.0, will be removed in 3.0.0. + * The 'deepseek-reasoner' alias retires from the DeepSeek API on 2026-07-24 + * (currently routes to deepseek-v4-flash thinking mode). + * Use Models::V4_FLASH with thinking mode instead. + */ + case REASONER = 'deepseek-reasoner'; + + /** + * @deprecated since 2.1.0, will be removed in 3.0.0. + * The 'deepseek-coder' model no longer exists in the DeepSeek API. + * Use Models::V4_PRO or Models::V4_FLASH instead. + */ case CODER = 'deepseek-coder'; + + /** + * @deprecated since 2.1.0, will be removed in 3.0.0. + * The 'DeepSeek-R1' alias retires from the DeepSeek API on 2026-07-24 + * (currently routes to deepseek-v4-flash thinking mode). + * Use Models::V4_FLASH with setThinking() (available v2.2.0+) instead. + */ case R1 = 'DeepSeek-R1'; + + /** + * @deprecated since 2.1.0, will be removed in 3.0.0. + * 'DeepSeek-R1-Zero' was never a valid DeepSeek API model id. + */ case R1Zero = 'DeepSeek-R1-Zero'; + + /** + * DeepSeek-V4-Pro: flagship model. 1M context, max 384K output tokens. + * Supports both thinking and non-thinking modes. + * + * @see https://api-docs.deepseek.com/quick_start/pricing + */ + case V4_PRO = 'deepseek-v4-pro'; + + /** + * DeepSeek-V4-Flash: fast, efficient, economical. 1M context, max 384K output tokens. + * Supports both thinking and non-thinking modes. + * + * @see https://api-docs.deepseek.com/quick_start/pricing + */ + case V4_FLASH = 'deepseek-v4-flash'; } diff --git a/src/Factories/ApiFactory.php b/src/Factories/ApiFactory.php index 0a38466..aa71bb4 100644 --- a/src/Factories/ApiFactory.php +++ b/src/Factories/ApiFactory.php @@ -7,62 +7,68 @@ use DeepSeek\Enums\Requests\ClientTypes; use DeepSeek\Enums\Requests\HeaderFlags; use GuzzleHttp\Client; +use InvalidArgumentException; use Psr\Http\Client\ClientInterface; +use RuntimeException; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\Psr18Client; -use RuntimeException; -use InvalidArgumentException; final class ApiFactory implements ApiFactoryContract { protected string $apiKey; + protected string $baseUrl; + protected int $timeout; + protected array $clientConfig; public static function build(): self { - return new self(); + return new self; } public function setBaseUri(?string $baseUrl = null): self { $this->baseUrl = $baseUrl ? trim($baseUrl) : DefaultConfigs::BASE_URL->value; + return $this; } public function setKey(string $apiKey): self { $this->apiKey = trim($apiKey); + return $this; } public function setTimeout(?int $timeout = null): self { - $this->timeout = $timeout ?: (int)DefaultConfigs::TIMEOUT->value; + $this->timeout = $timeout ?: (int) DefaultConfigs::TIMEOUT->value; + return $this; } public function initialize(): self { - if (!isset($this->baseUrl)) { + if (! isset($this->baseUrl)) { $this->setBaseUri(); } - if (!isset($this->apiKey)) { + if (! isset($this->apiKey)) { throw new RuntimeException('API key must be set using setKey() before initialization.'); } - if (!isset($this->timeout)) { + if (! isset($this->timeout)) { $this->setTimeout(); } $this->clientConfig = [ HeaderFlags::BASE_URL->value => $this->baseUrl, - HeaderFlags::TIMEOUT->value => $this->timeout, - HeaderFlags::HEADERS->value => [ - HeaderFlags::AUTHORIZATION->value => 'Bearer ' . $this->apiKey, - HeaderFlags::CONTENT_TYPE->value => 'application/json', + HeaderFlags::TIMEOUT->value => $this->timeout, + HeaderFlags::HEADERS->value => [ + HeaderFlags::AUTHORIZATION->value => 'Bearer '.$this->apiKey, + HeaderFlags::CONTENT_TYPE->value => 'application/json', ], ]; @@ -73,7 +79,7 @@ public function run(?string $clientType = null): ClientInterface { $clientType = $clientType ?? ClientTypes::GUZZLE->value; - if (!isset($this->clientConfig)) { + if (! isset($this->clientConfig)) { $this->initialize(); } diff --git a/src/Models/BadResult.php b/src/Models/BadResult.php index 5bc9608..3b67e8e 100644 --- a/src/Models/BadResult.php +++ b/src/Models/BadResult.php @@ -4,8 +4,4 @@ namespace DeepSeek\Models; -class BadResult extends ResultAbstract -{ - -} - +class BadResult extends ResultAbstract {} diff --git a/src/Models/FailureResult.php b/src/Models/FailureResult.php index b8fb272..2b7be45 100644 --- a/src/Models/FailureResult.php +++ b/src/Models/FailureResult.php @@ -4,8 +4,4 @@ namespace DeepSeek\Models; -class FailureResult extends ResultAbstract -{ - -} - +class FailureResult extends ResultAbstract {} diff --git a/src/Models/ResultAbstract.php b/src/Models/ResultAbstract.php index a5b121a..2afc6e4 100644 --- a/src/Models/ResultAbstract.php +++ b/src/Models/ResultAbstract.php @@ -11,47 +11,61 @@ abstract class ResultAbstract implements ResultContract { protected ?int $statusCode; + protected ?string $content; + /** * handel response coming from request - * @var ResponseInterface|null */ protected ?ResponseInterface $response; + public function __construct(?int $statusCode = null, ?string $content = null) { $this->statusCode = $statusCode; $this->content = $content; } - protected function setStatusCode(int $statusCode) + + protected function setStatusCode(int $statusCode): void { $this->statusCode = $statusCode; } + public function getStatusCode(): int { return $this->statusCode; } + protected function setContent(string $content): void { - $this->content = $content; + // Strip DeepSeek Keep-Alive padding per + // https://api-docs.deepseek.com/quick_start/rate_limit#request-keep-alive-mechanism + // - Non-streaming responses: leading empty lines before the JSON body + // - Streaming responses: ": keep-alive" SSE comment lines anywhere in the stream + $content = preg_replace('/^: keep-alive\R/m', '', $content) ?? $content; + $this->content = ltrim($content, "\r\n"); } + public function getContent(): string { return $this->content; } + public function setResponse(ResponseInterface $response): static { $this->response = $response; $this->setStatusCode($this->getResponse()->getStatusCode()); $this->setContent($this->getResponse()->getBody()->getContents()); + return $this; } + public function getResponse(): ResponseInterface { return $this->response; } + public function isSuccess(): bool { - return ($this->getStatusCode() === HTTPState::OK->value); + return $this->getStatusCode() === HTTPState::OK->value; } } - diff --git a/src/Models/SuccessResult.php b/src/Models/SuccessResult.php index 2cd0fd3..e56bb87 100644 --- a/src/Models/SuccessResult.php +++ b/src/Models/SuccessResult.php @@ -4,8 +4,4 @@ namespace DeepSeek\Models; -class SuccessResult extends ResultAbstract -{ - -} - +class SuccessResult extends ResultAbstract {} diff --git a/src/Resources/Chat.php b/src/Resources/Chat.php index 1863a52..a0edd19 100644 --- a/src/Resources/Chat.php +++ b/src/Resources/Chat.php @@ -4,7 +4,4 @@ namespace DeepSeek\Resources; -class Chat extends Resource -{ - -} +class Chat extends Resource {} diff --git a/src/Resources/Coder.php b/src/Resources/Coder.php index 412beed..99cc9ec 100644 --- a/src/Resources/Coder.php +++ b/src/Resources/Coder.php @@ -17,6 +17,6 @@ class Coder extends Resource */ public function getDefaultModel(): string { - return Models::CODER->value; + return Models::V4_FLASH->value; } } diff --git a/src/Resources/Resource.php b/src/Resources/Resource.php index 65ed2fe..ed4fc41 100644 --- a/src/Resources/Resource.php +++ b/src/Resources/Resource.php @@ -7,8 +7,8 @@ use DeepSeek\Contracts\Models\ResultContract; use DeepSeek\Contracts\Resources\ResourceContract; use DeepSeek\Enums\Configs\DefaultConfigs; -use DeepSeek\Enums\Models; use DeepSeek\Enums\Data\DataTypes; +use DeepSeek\Enums\Models; use DeepSeek\Enums\Requests\EndpointSuffixes; use DeepSeek\Enums\Requests\QueryFlags; use DeepSeek\Models\BadResult; @@ -17,18 +17,21 @@ use DeepSeek\Traits\Queries\HasQueryParams; use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Exception\GuzzleException; +use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; -use Nyholm\Psr7\Factory\Psr17Factory; class Resource implements ResourceContract { use HasQueryParams; protected ClientInterface $client; + protected ?string $endpointSuffixes; + protected RequestFactoryInterface $requestFactory; + protected StreamFactoryInterface $streamFactory; public function __construct( @@ -39,8 +42,8 @@ public function __construct( ) { $this->client = $client; $this->endpointSuffixes = $endpointSuffixes ?: EndpointSuffixes::CHAT->value; - $this->requestFactory = $requestFactory ?: new Psr17Factory(); - $this->streamFactory = $streamFactory ?: new Psr17Factory(); + $this->requestFactory = $requestFactory ?: new Psr17Factory; + $this->streamFactory = $streamFactory ?: new Psr17Factory; } public function sendRequest(array $requestData, ?string $requestMethod = 'POST'): ResultContract @@ -62,9 +65,9 @@ public function sendRequest(array $requestData, ?string $requestMethod = 'POST') $response = $this->client->sendRequest($request); - return (new SuccessResult())->setResponse($response); + return (new SuccessResult)->setResponse($response); } catch (BadResponseException $badResponse) { - return (new BadResult())->setResponse($badResponse->getResponse()); + return (new BadResult)->setResponse($badResponse->getResponse()); } catch (GuzzleException $error) { return new FailureResult($error->getCode(), $error->getMessage()); } catch (\Exception $error) { @@ -78,7 +81,7 @@ public function sendRequest(array $requestData, ?string $requestMethod = 'POST') * This method merges the given query data with custom headers that are * prepared for the request. * - * @param array $requestData The data to send in the request. + * @param array $requestData The data to send in the request. * @return array The merged request data with default headers. */ protected function resolveHeaders(array $requestData): array @@ -92,7 +95,7 @@ protected function resolveHeaders(array $requestData): array * This method loops through the query parameters and applies the appropriate * type conversion before returning the final headers. * - * @param array $query The data to send in the request. + * @param array $query The data to send in the request. * @return array The custom headers for the request. */ public function prepareCustomHeaderParams(array $query): array @@ -129,7 +132,7 @@ public function getEndpointSuffix(): string */ public function getDefaultModel(): string { - return Models::CHAT->value; + return Models::V4_FLASH->value; } /** @@ -142,7 +145,7 @@ public function getDefaultModel(): string */ public function getDefaultStream(): bool { - return DefaultConfigs::STREAM->value === 'true'; + return filter_var(DefaultConfigs::STREAM->value, FILTER_VALIDATE_BOOLEAN); } /** diff --git a/src/Traits/Client/HasToolsFunctionCalling.php b/src/Traits/Client/HasToolsFunctionCalling.php index 97d53fc..9cae45b 100644 --- a/src/Traits/Client/HasToolsFunctionCalling.php +++ b/src/Traits/Client/HasToolsFunctionCalling.php @@ -7,26 +7,26 @@ trait HasToolsFunctionCalling { /** - * @param array $tools A list of tools the model may call. + * @param array $tools A list of tools the model may call. * @return self The current instance for method chaining. */ public function setTools(array $tools): self { $this->tools = $tools; + return $this; } /** * Add a query tool calls to the accumulated queries list. * - * @param array $toolCalls The tool calls generated by the model, such as function calls. - * @param string $content - * @param string|null $role + * @param array $toolCalls The tool calls generated by the model, such as function calls. * @return self The current instance for method chaining. */ public function queryToolCall(array $toolCalls, string $content, ?string $role = null): self { $this->queries[] = $this->buildToolCallQuery($toolCalls, $content, $role); + return $this; } @@ -37,20 +37,19 @@ public function buildToolCallQuery(array $toolCalls, string $content, ?string $r 'tool_calls' => $toolCalls, 'content' => $content, ]; + return $query; } /** * Add a query tool to the accumulated queries list. * - * @param string $toolCallId - * @param string $content - * @param string|null $role * @return self The current instance for method chaining. */ - public function queryTool(string $toolCallId, string $content , ?string $role = null): self + public function queryTool(string $toolCallId, string $content, ?string $role = null): self { $this->queries[] = $this->buildToolQuery($toolCallId, $content, $role); + return $this; } @@ -61,6 +60,7 @@ public function buildToolQuery(string $toolCallId, string $content, ?string $rol 'tool_call_id' => $toolCallId, 'content' => $content, ]; + return $query; } } diff --git a/src/Traits/Queries/HasQueryParams.php b/src/Traits/Queries/HasQueryParams.php index bb2a1a8..aafa3cd 100644 --- a/src/Traits/Queries/HasQueryParams.php +++ b/src/Traits/Queries/HasQueryParams.php @@ -10,16 +10,13 @@ trait HasQueryParams /** * Helper method to get the query parameter or default value with type conversion. * - * @param array $query - * @param string $key - * @param mixed $default - * @param string $type - * @return mixed + * @param mixed $default */ private function getQueryParam(array $query, string $key, $default, string $type): mixed { if (isset($query[$key])) { $value = $query[$key]; + return $this->convertValue($value, $type); } @@ -29,29 +26,24 @@ private function getQueryParam(array $query, string $key, $default, string $type /** * Convert the value to the specified type. * - * @param mixed $value - * @param string $type - * @return mixed + * @param mixed $value */ private function convertValue($value, string $type): mixed { return match ($type) { - DataTypes::STRING->value => (string)$value, - DataTypes::INTEGER->value => (int)$value, - DataTypes::FLOAT->value => (float)$value, - DataTypes::ARRAY->value => (array)$value, - DataTypes::OBJECT->value => (object)$value, - DataTypes::BOOL->value => (bool)$value, - DataTypes::JSON->value => json_decode((string)$value, true), + DataTypes::STRING->value => (string) $value, + DataTypes::INTEGER->value => (int) $value, + DataTypes::FLOAT->value => (float) $value, + DataTypes::ARRAY->value => (array) $value, + DataTypes::OBJECT->value => (object) $value, + DataTypes::BOOL->value => (bool) $value, + DataTypes::JSON->value => json_decode((string) $value, true), default => $value, }; } /** * Get default value for specific query keys. - * - * @param string $key - * @return mixed */ private function getDefaultForKey(string $key): mixed { diff --git a/src/Traits/Resources/HasChat.php b/src/Traits/Resources/HasChat.php index 02cabdd..b03063e 100644 --- a/src/Traits/Resources/HasChat.php +++ b/src/Traits/Resources/HasChat.php @@ -2,6 +2,7 @@ namespace DeepSeek\Traits\Resources; +use DeepSeek\Enums\Requests\QueryFlags; use DeepSeek\Resources\Chat; trait HasChat @@ -9,17 +10,26 @@ trait HasChat /** * Send the accumulated queries to the Chat resource. * - * @return string + * Since 2.1.0, this shortcut honors the same configuration as run(): + * temperature, max_tokens, tools, and response_format set via the + * corresponding setters are now included in the request body. */ public function chat(): string { $requestData = [ - 'messages' => $this->queries, - 'model' => $this->model, - 'stream' => $this->stream, + QueryFlags::MESSAGES->value => $this->queries, + QueryFlags::MODEL->value => $this->model, + QueryFlags::STREAM->value => $this->stream, + QueryFlags::TEMPERATURE->value => $this->temperature, + QueryFlags::MAX_TOKENS->value => $this->maxTokens, + QueryFlags::TOOLS->value => $this->tools, + QueryFlags::RESPONSE_FORMAT->value => [ + 'type' => $this->responseFormatType, + ], ]; $this->queries = []; $this->setResult((new Chat($this->httpClient))->sendRequest($requestData)); + return $this->getResult()->getContent(); } } diff --git a/src/Traits/Resources/HasCoder.php b/src/Traits/Resources/HasCoder.php index 9a684eb..4ef5d52 100644 --- a/src/Traits/Resources/HasCoder.php +++ b/src/Traits/Resources/HasCoder.php @@ -2,6 +2,7 @@ namespace DeepSeek\Traits\Resources; +use DeepSeek\Enums\Requests\QueryFlags; use DeepSeek\Resources\Coder; trait HasCoder @@ -9,17 +10,26 @@ trait HasCoder /** * Send the accumulated queries to the code resource. * - * @return string + * Since 2.1.0, this shortcut honors the same configuration as run(): + * temperature, max_tokens, tools, and response_format set via the + * corresponding setters are now included in the request body. */ public function code(): string { $requestData = [ - 'messages' => $this->queries, - 'model' => $this->model, - 'stream' => $this->stream, + QueryFlags::MESSAGES->value => $this->queries, + QueryFlags::MODEL->value => $this->model, + QueryFlags::STREAM->value => $this->stream, + QueryFlags::TEMPERATURE->value => $this->temperature, + QueryFlags::MAX_TOKENS->value => $this->maxTokens, + QueryFlags::TOOLS->value => $this->tools, + QueryFlags::RESPONSE_FORMAT->value => [ + 'type' => $this->responseFormatType, + ], ]; $this->queries = []; $this->setResult((new Coder($this->httpClient))->sendRequest($requestData)); + return $this->getResult()->getContent(); } } diff --git a/tests/Feature/ClientDependency/FakeResponse.php b/tests/Feature/ClientDependency/FakeResponse.php index 3b1b725..f2d3d53 100644 --- a/tests/Feature/ClientDependency/FakeResponse.php +++ b/tests/Feature/ClientDependency/FakeResponse.php @@ -2,11 +2,11 @@ namespace Tests\Feature\ClientDependency; -class FakeResponse +class FakeResponse { public function toolFunctionCalling() { - return <<createStream('{"id":"abc","object":"chat.completion","choices":[{"message":{"content":"Hello!","role":"assistant"}}]}'); + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest')->once()->andReturn( + $factory->createResponse(200)->withBody($body) + ); + + $client = (new DeepSeekClient($httpClient)) ->query('Hello DeepSeek, how are you today?') ->setTemperature(1.5); - // Act $response = $client->run(); $result = $client->getResult(); - // Assert - expect($response)->not->toBeEmpty($response) + expect($response)->not->toBeEmpty() ->and($result->getStatusCode())->toEqual(HTTPState::OK->value); }); test('Run query with valid API Key & insufficient balance should return 402', function () { - // Arrange - $apiKey = "insufficient-balance-api-key"; - $client = DeepSeekClient::build($apiKey) + $factory = new Psr17Factory; + $body = $factory->createStream('{"error":{"message":"Insufficient balance","type":"insufficient_quota"}}'); + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest')->once()->andReturn( + $factory->createResponse(402)->withBody($body) + ); + + $client = (new DeepSeekClient($httpClient)) ->query('Hello DeepSeek, how are you today?') ->setTemperature(1.5); - // Act $response = $client->run(); $result = $client->getResult(); - // Assert - expect($response)->not->toBeEmpty($response) + expect($response)->not->toBeEmpty() ->and($result->getStatusCode())->toEqual(HTTPState::PAYMENT_REQUIRED->value); }); test('Run query with invalid API key should return 401', function () { - // Arrange - $apiKey = "insufficient-balance-api-key"; - $client = DeepSeekClient::build($apiKey) + $factory = new Psr17Factory; + $body = $factory->createStream('{"error":{"message":"Authentication Fails","type":"authentication_error"}}'); + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest')->once()->andReturn( + $factory->createResponse(401)->withBody($body) + ); + + $client = (new DeepSeekClient($httpClient)) ->query('Hello DeepSeek, how are you today?') ->setTemperature(1.5); - // Act $response = $client->run(); $result = $client->getResult(); - // Assert - expect($response)->not->toBeEmpty($response) + expect($response)->not->toBeEmpty() ->and($result->getStatusCode())->toEqual(HTTPState::UNAUTHORIZED->value); }); diff --git a/tests/Feature/FunctionCallingTest.php b/tests/Feature/FunctionCallingTest.php index 041e153..71a9b58 100644 --- a/tests/Feature/FunctionCallingTest.php +++ b/tests/Feature/FunctionCallingTest.php @@ -1,157 +1,194 @@ ["temperature"=> 22, "condition" => "Sunny"], - "gharbia" => ["temperature"=> 23, "condition" => "Sunny"], - "sharkia" => ["temperature"=> 24, "condition" => "Sunny"], - "beheira" => ["temperature"=> 21, "condition" => "Sunny"], - default => "not found city name." + $city = match ($city) { + 'cairo' => ['temperature' => 22, 'condition' => 'Sunny'], + 'gharbia' => ['temperature' => 23, 'condition' => 'Sunny'], + 'sharkia' => ['temperature' => 24, 'condition' => 'Sunny'], + 'beheira' => ['temperature' => 21, 'condition' => 'Sunny'], + default => 'not found city name.' }; + return json_encode($city); } test('Test function calling with fake responses.', function () { // Arrange - $fake = new FakeResponse(); + $fake = new FakeResponse; /** @var DeepSeekClient&LegacyMockInterface&MockInterface */ - $mockClient = Mockery::mock(DeepSeekClient::class); - + $mockClient = Mockery::mock(DeepSeekClient::class); + $mockClient->shouldReceive('build')->andReturn($mockClient); $mockClient->shouldReceive('setTools')->andReturn($mockClient); $mockClient->shouldReceive('query')->andReturn($mockClient); $mockClient->shouldReceive('run')->once()->andReturn($fake->toolFunctionCalling()); - + // Act $response = $mockClient::build('your-api-key') ->query('What is the weather like in Cairo?') ->setTools([ [ - "type" => "function", - "function" => [ - "name" => "get_weather", - "description" => "Get the current weather in a given city", - "parameters" => [ - "type" => "object", - "properties" => [ - "city" => [ - "type" => "string", - "description" => "The city name", + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given city', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'city' => [ + 'type' => 'string', + 'description' => 'The city name', ], ], - "required" => ["city"], + 'required' => ['city'], ], ], ], ] - )->run(); + )->run(); // Assert expect($fake->toolFunctionCalling())->toEqual($response); - - //------------------------------------------ - - // Arrange + + // ------------------------------------------ + + // Arrange $response = json_decode($response, true); $message = $response['choices'][0]['message']; - + $firstFunction = $message['tool_calls'][0]; - if ($firstFunction['function']['name'] == "get_weather") - { + if ($firstFunction['function']['name'] == 'get_weather') { $weather_data = get_weather($firstFunction['function']['arguments']['city']); } $mockClient->shouldReceive('queryCallTool')->andReturn($mockClient); $mockClient->shouldReceive('queryTool')->andReturn($mockClient); $mockClient->shouldReceive('run')->andReturn($fake->resultToolFunctionCalling()); - + // Act $response2 = $mockClient->queryCallTool( - $message['tool_calls'], - $message['content'], - $message['role'] - )->queryTool( - $firstFunction['id'], - $weather_data, - 'tool' + $message['tool_calls'], + $message['content'], + $message['role'] + )->queryTool( + $firstFunction['id'], + $weather_data, + 'tool' )->run(); // Assert expect($fake->resultToolFunctionCalling())->toEqual($response2); }); -test('Test function calling use base data with real responses.', function () { - // Arrange - $client = DeepSeekClient::build('your-api-key') +afterEach(function () { + Mockery::close(); +}); + +test('Test function calling use base data with mocked responses.', function () { + $factory = new Psr17Factory; + + // First HTTP response: model asks to call get_weather + $toolCallBody = (string) json_encode([ + 'id' => 'test-fc-1', + 'choices' => [[ + 'finish_reason' => 'tool_calls', + 'index' => 0, + 'message' => [ + 'content' => '', + 'role' => 'assistant', + 'tool_calls' => [[ + 'id' => 'call-abc123', + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => json_encode(['city' => 'Cairo']), + ], + ]], + ], + ]], + 'usage' => ['completion_tokens' => 12, 'prompt_tokens' => 20, 'total_tokens' => 32], + ]); + + // Second HTTP response: model returns final answer after receiving tool result + $finalBody = (string) json_encode([ + 'id' => 'test-fc-2', + 'choices' => [[ + 'finish_reason' => 'stop', + 'index' => 0, + 'message' => [ + 'content' => 'The weather in Cairo is sunny with a temperature of 22 degrees.', + 'role' => 'assistant', + ], + ]], + 'usage' => ['completion_tokens' => 20, 'prompt_tokens' => 40, 'total_tokens' => 60], + ]); + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest') + ->andReturn( + $factory->createResponse(200)->withBody($factory->createStream($toolCallBody)), + $factory->createResponse(200)->withBody($factory->createStream($finalBody)) + ); + + $client = new DeepSeekClient($httpClient); + + // Act — first call: model responds with a tool call + $response = $client ->query('What is the weather like in Cairo?') - ->setTools([ - [ - "type" => "function", - "function" => [ - "name" => "get_weather", - "description" => "Get the current weather in a given city", - "parameters" => [ - "type" => "object", - "properties" => [ - "city" => [ - "type" => "string", - "description" => "The city name", - ], - ], - "required" => ["city"], + ->setTools([[ + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'description' => 'Get the current weather in a given city', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'city' => ['type' => 'string', 'description' => 'The city name'], ], + 'required' => ['city'], ], ], - ] - ); - - // Act - $response = $client->run(); + ]]) + ->run(); $result = $client->getResult(); - // Assert - expect($response)->not()->toBeEmpty($response) + // Assert first response + expect($response)->not()->toBeEmpty() ->and($result->getStatusCode())->toEqual(HTTPState::OK->value); - //----------------------------------------------------------------- - - // Arrange - $response = json_decode($response, true); - - $message = $response['choices'][0]['message']; + // Execute the tool locally + $decoded = json_decode($response, true); + $message = $decoded['choices'][0]['message']; $firstFunction = $message['tool_calls'][0]; - if ($firstFunction['function']['name'] == "get_weather") - { + $weather_data = ''; + if ($firstFunction['function']['name'] === 'get_weather') { $args = json_decode($firstFunction['function']['arguments'], true); $weather_data = get_weather($args['city']); } - - $client2 = $client->queryToolCall( - $message['tool_calls'], - $message['content'], - $message['role'] - )->queryTool( - $firstFunction['id'], - $weather_data, - 'tool' - ); - - // Act - $response2 = $client2->run(); - $result2 = $client2->getResult(); - // Assert - expect($response2)->not()->toBeEmpty($response2) + // Act — second call: send tool result back to model + $response2 = $client + ->queryToolCall($message['tool_calls'], $message['content'], $message['role']) + ->queryTool($firstFunction['id'], $weather_data, 'tool') + ->run(); + $result2 = $client->getResult(); + + // Assert final response + expect($response2)->not()->toBeEmpty() ->and($result2->getStatusCode())->toEqual(HTTPState::OK->value); }); diff --git a/tests/Feature/V210ChangesTest.php b/tests/Feature/V210ChangesTest.php new file mode 100644 index 0000000..2416ebc --- /dev/null +++ b/tests/Feature/V210ChangesTest.php @@ -0,0 +1,146 @@ +value)->toBe('deepseek-v4-pro') + ->and(Models::V4_FLASH->value)->toBe('deepseek-v4-flash'); +}); + +test('default base URL no longer includes the /v3 suffix', function () { + expect(DefaultConfigs::BASE_URL->value)->toBe('https://api.deepseek.com'); +}); + +test('legacy Models cases remain functional for backward compatibility', function () { + expect(Models::CHAT->value)->toBe('deepseek-chat') + ->and(Models::CODER->value)->toBe('deepseek-coder') + ->and(Models::R1->value)->toBe('DeepSeek-R1') + ->and(Models::R1Zero->value)->toBe('DeepSeek-R1-Zero'); +}); + +test('SuccessResult strips leading empty lines (non-streaming keep-alive)', function () { + $factory = new Psr17Factory; + $body = $factory->createStream("\n\n\n".'{"id":"abc","object":"chat.completion"}'); + $response = $factory->createResponse(200)->withBody($body); + + $result = (new SuccessResult)->setResponse($response); + + expect($result->getContent())->toBe('{"id":"abc","object":"chat.completion"}'); +}); + +test('SuccessResult strips ": keep-alive" SSE comments (streaming)', function () { + $factory = new Psr17Factory; + $body = $factory->createStream( + "data: {\"chunk\":1}\n". + ": keep-alive\n". + "data: {\"chunk\":2}\n". + ": keep-alive\n". + "data: [DONE]\n" + ); + $response = $factory->createResponse(200)->withBody($body); + + $result = (new SuccessResult)->setResponse($response); + + expect($result->getContent())->not->toContain(': keep-alive') + ->and($result->getContent())->toContain('data: {"chunk":1}') + ->and($result->getContent())->toContain('data: {"chunk":2}') + ->and($result->getContent())->toContain('data: [DONE]'); +}); + +test('SuccessResult does not corrupt already-clean responses', function () { + $factory = new Psr17Factory; + $clean = '{"id":"xyz","object":"chat.completion","choices":[]}'; + $body = $factory->createStream($clean); + $response = $factory->createResponse(200)->withBody($body); + + $result = (new SuccessResult)->setResponse($response); + + expect($result->getContent())->toBe($clean); +}); + +test('chat() shortcut includes temperature, max_tokens, tools, response_format in the request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest') + ->once() + ->andReturnUsing(function (RequestInterface $request) use ($factory, &$capturedBody): ResponseInterface { + $capturedBody = (string) $request->getBody(); + + return $factory->createResponse(200)->withBody($factory->createStream('{"id":"ok"}')); + }); + + $tools = [[ + 'type' => 'function', + 'function' => ['name' => 'noop', 'description' => 'noop'], + ]]; + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_FLASH->value) + ->setTemperature(0.7) + ->setMaxTokens(2048) + ->setResponseFormat('text') + ->setTools($tools) + ->query('Hello') + ->chat(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toBeArray() + ->toHaveKeys(['messages', 'model', 'stream', 'temperature', 'max_tokens', 'tools', 'response_format']) + ->and($decoded['model'])->toBe('deepseek-v4-flash') + ->and($decoded['temperature'])->toBe(0.7) + ->and($decoded['max_tokens'])->toBe(2048) + ->and($decoded['response_format'])->toBe(['type' => 'text']) + ->and($decoded['tools'])->toBe($tools); +}); + +test('code() shortcut includes temperature, max_tokens, tools, response_format in the request body', function () { + $factory = new Psr17Factory; + $capturedBody = null; + + /** @var ClientInterface&LegacyMockInterface&MockInterface $httpClient */ + $httpClient = Mockery::mock(ClientInterface::class); + $httpClient->shouldReceive('sendRequest') + ->once() + ->andReturnUsing(function (RequestInterface $request) use ($factory, &$capturedBody): ResponseInterface { + $capturedBody = (string) $request->getBody(); + + return $factory->createResponse(200)->withBody($factory->createStream('{"id":"ok"}')); + }); + + (new DeepSeekClient($httpClient)) + ->withModel(Models::V4_PRO->value) + ->setTemperature(0.5) + ->setMaxTokens(1024) + ->query('def fib(n):') + ->code(); + + $decoded = json_decode((string) $capturedBody, true); + + expect($decoded) + ->toBeArray() + ->toHaveKeys(['messages', 'model', 'stream', 'temperature', 'max_tokens', 'response_format']) + ->and($decoded['model'])->toBe('deepseek-v4-pro') + ->and($decoded['temperature'])->toBe(0.5) + ->and($decoded['max_tokens'])->toBe(1024); +});