diff --git a/CHANGELOG.md b/CHANGELOG.md index 705224c..165cef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.1.15 - 2026-08-02 + +### Added + +- Added first-class DeepSeek V4 Flash and Pro support across Chat Completions, + beta prefix/strict tools, Responses, FIM, model listing, balance, and the + Anthropic-compatible Messages endpoint, with lazy streaming, normalized + reasoning/cache usage, current pricing, and live conformance coverage. + +### Fixed + +- Updated named Finch pool options for Req 0.7 so synchronous and streaming + requests no longer emit the deprecated `finch: name` warning. +- Updated the shared Ecto adapter to use `Repo.transact/1`, preserving its + existing result contract without invoking Ecto's deprecated transaction API. +- Synchronized shell timeout cleanup with stderr initialization so timed-out + host and session commands no longer emit missing-scratch-file warnings or + leave scratch directories and Port messages behind. +- Made record-manager key pagination deterministic when records share a + timestamp, matching the Postgres adapter's timestamp-and-ID ordering. +- Updated GPT-5.6 Terra and Luna pricing metadata to OpenAI's July 30 rates, + added current Batch/Flex/Fast multipliers, and documented Fast mode's + backward-compatible `priority` service-tier alias. + ## 0.1.14 - 2026-07-20 ### Added diff --git a/README.md b/README.md index 4cd8692..246abbe 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ prefixes when a model name is ambiguous. | OpenAI | `openai:gpt-5.6-sol`, `openai:gpt-5.6-terra`, `openai:gpt-5.6-luna`, `openai:gpt-5.6`, `openai:gpt-5.5`, `openai:gpt-5.4`, `openai:gpt-5.4-mini`, `openai:gpt-4.1`, `openai:text-embedding-3-large`, `openai:text-embedding-3-small` | | Anthropic | `anthropic:claude-opus-5`, `anthropic:claude-sonnet-5`, `anthropic:claude-opus-4-8`, `anthropic:claude-opus-4-7`, `anthropic:claude-opus-4-6`, `anthropic:claude-opus-4-5`, `anthropic:claude-sonnet-4-6`, `anthropic:claude-sonnet-4-5`, `anthropic:claude-haiku-4-5`, `anthropic:claude-fable-5`, `anthropic:claude-mythos-5` | | Google Gemini | `google:gemini-3.6-flash`, `google:gemini-3.5-flash-lite`, `google:gemini-3.5-flash`, `google:gemini-3.1-pro-preview` | +| DeepSeek | `deepseek:deepseek-v4-flash`, `deepseek:deepseek-v4-pro` | | Moonshot/Kimi | `moonshot:kimi-k3`, `moonshot:kimi-k2.7-code`, `moonshot:kimi-k2.7-code-highspeed`, `moonshot:kimi-k2.6`, `moonshot:kimi-k2.5` | | xAI | `xai:grok-4.5`, `xai:grok-4.3`, `xai:grok-4.20-0309-reasoning`, `xai:grok-4.20-0309-non-reasoning`, `xai:grok-4.20-multi-agent-0309`, `xai:grok-build-0.1`, `xai:v1` embeddings | | Z.ai | `zai:glm-5.2` | @@ -154,6 +155,7 @@ config :beam_weaver, openai: [api_key: System.fetch_env!("OPENAI_API_KEY")], anthropic: [api_key: System.fetch_env!("ANTHROPIC_API_KEY")], google: [api_key: System.fetch_env!("GOOGLE_API_KEY")], + deepseek: [api_key: System.fetch_env!("DEEPSEEK_API_KEY")], xai: [api_key: System.fetch_env!("XAI_API_KEY")], moonshot: [api_key: System.fetch_env!("MOONSHOT_API_KEY")], zai: [api_key: System.fetch_env!("ZAI_API_KEY")] @@ -287,6 +289,7 @@ Provider guides: - [OpenAI](docs/partners/openai.md) - [Anthropic](docs/partners/anthropic.md) - [Google](docs/partners/google.md) +- [DeepSeek](docs/partners/deepseek.md) - [Moonshot/Kimi](docs/partners/moonshot.md) - [xAI](docs/partners/xai.md) - [Z.ai](docs/partners/zai.md) diff --git a/config/config.exs b/config/config.exs index 8116594..158c6cf 100644 --- a/config/config.exs +++ b/config/config.exs @@ -3,6 +3,7 @@ import Config config :beam_weaver, openai: [], anthropic: [], + deepseek: [], google: [], xai: [], zai: [], diff --git a/config/runtime.exs b/config/runtime.exs index 92ce39d..df26990 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -90,6 +90,18 @@ if config_env() != :test do ) ) + config :beam_weaver, + :deepseek, + Keyword.merge( + Application.get_env(:beam_weaver, :deepseek, []), + compact.( + api_key: non_blank.("DEEPSEEK_API_KEY"), + base_url: first_env.(["DEEPSEEK_BASE_URL", "DEEPSEEK_API_URL"]), + beta_base_url: first_env.(["DEEPSEEK_BETA_BASE_URL", "DEEPSEEK_BETA_API_URL"]), + anthropic_base_url: first_env.(["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_ANTHROPIC_API_URL"]) + ) + ) + provider_api_keys = compact.( openai: non_blank.("OPENAI_API_KEY"), @@ -97,7 +109,8 @@ if config_env() != :test do google: google_api_key, xai: non_blank.("XAI_API_KEY"), moonshot: non_blank.("MOONSHOT_API_KEY"), - zai: non_blank.("ZAI_API_KEY") + zai: non_blank.("ZAI_API_KEY"), + deepseek: non_blank.("DEEPSEEK_API_KEY") ) sandbox_env = non_blank.("BEAM_WEAVER_DEEPAGENTS_SANDBOX") @@ -143,5 +156,6 @@ config :beam_weaver, :examples, "google" => examples_env.("GOOGLE_API_KEY") || examples_env.("GEMINI_API_KEY"), "xai" => examples_env.("XAI_API_KEY"), "moonshot" => examples_env.("MOONSHOT_API_KEY") || examples_env.("KIMI_API_KEY"), - "zai" => examples_env.("ZAI_API_KEY") + "zai" => examples_env.("ZAI_API_KEY"), + "deepseek" => examples_env.("DEEPSEEK_API_KEY") } diff --git a/docs/README.md b/docs/README.md index cf0bb59..d30199c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -100,7 +100,7 @@ history, and `stream_events/3`. Agents use the same graph runtime underneath. | Capability | BeamWeaver surface | | --- | --- | -| Standard model interface | `BeamWeaver.Core.ChatModel` plus provider adapters for OpenAI, Anthropic, Google, xAI, Z.ai, fake, and replay-backed tests. | +| Standard model interface | `BeamWeaver.Core.ChatModel` plus provider adapters for OpenAI, Anthropic, Google, DeepSeek, xAI, Z.ai, fake, and replay-backed tests. | | Agent architecture | `use BeamWeaver.Agent` and `BeamWeaver.Agent.build/1` for graph-backed model/tool loops. | | Low-level orchestration | `BeamWeaver.Graph` for deterministic, agentic, or hybrid workflows. | | Durable execution | [Checkpoint-backed resumable graph execution](durable_execution.md). | @@ -177,6 +177,7 @@ history, and `stream_events/3`. Agents use the same graph runtime underneath. - [OpenAI](partners/openai.md) - [Anthropic](partners/anthropic.md) - [Google](partners/google.md) +- [DeepSeek](partners/deepseek.md) - [Moonshot/Kimi](partners/moonshot.md) - [xAI](partners/xai.md) - [Z.ai](partners/zai.md) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c53efa2..d275554 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -58,6 +58,7 @@ * [OpenAI](partners/openai.md) * [Anthropic](partners/anthropic.md) * [Google](partners/google.md) +* [DeepSeek](partners/deepseek.md) * [Moonshot/Kimi](partners/moonshot.md) * [xAI](partners/xai.md) * [Z.ai](partners/zai.md) diff --git a/docs/event_streaming.md b/docs/event_streaming.md index 2f29a12..645d638 100644 --- a/docs/event_streaming.md +++ b/docs/event_streaming.md @@ -27,12 +27,15 @@ also expose provider-specific `stream_events/3` lifecycle streams; use those when you need the raw provider semantic lifecycle rather than the normalized BeamWeaver event contract. -Standalone OpenAI, Anthropic, Google, xAI, and Z.ai model streams are lazy live +Standalone OpenAI, Anthropic, Google, DeepSeek, xAI, and Z.ai model streams are lazy live enumerables when using the live transport. Provider chunks are parsed incrementally as server-sent events arrive. In tests, fake or replay transports can emit deterministic typed stream events from fixtures. If a lazy provider stream fails before any model output is emitted, consumers see an `%BeamWeaver.Stream.Events.Error{}` item when they enumerate the stream. +Raw provider lazy-stream helpers can also accept an `on_response` callback when +the caller needs transport status and headers without changing the lazy return +type. {% hint style="info" %} **Versionless Typed Events** @@ -327,8 +330,8 @@ events in one pass for live UI updates, or reduce collected events into ## Reasoning Content -Reasoning output is provider-dependent. OpenAI, Anthropic, Google, xAI, and -Z.ai can surface reasoning as content blocks in `%Events.MessageChunk{}` +Reasoning output is provider-dependent. OpenAI, Anthropic, Google, DeepSeek, +xAI, and Z.ai can surface reasoning as content blocks in `%Events.MessageChunk{}` events. Treat those chunk events as the live thinking stream; final assistant messages and text projections should be considered answer/tool output, not the primary place to read incremental reasoning. diff --git a/docs/models.md b/docs/models.md index 1865578..5f2a2b7 100644 --- a/docs/models.md +++ b/docs/models.md @@ -27,13 +27,14 @@ config :beam_weaver, anthropic: [api_key: System.fetch_env!("ANTHROPIC_API_KEY")], xai: [api_key: System.fetch_env!("XAI_API_KEY")], google: [api_key: System.fetch_env!("GOOGLE_API_KEY")], + deepseek: [api_key: System.fetch_env!("DEEPSEEK_API_KEY")], zai: [api_key: System.fetch_env!("ZAI_API_KEY")] ``` `BeamWeaver.Models.init_chat_model/2` accepts provider-prefixed identifiers. Unprefixed `gpt-*` and `o*` names infer OpenAI. Unprefixed `claude-*` names infer -Anthropic. Unprefixed `grok-*` names infer xAI. Gemini and GLM models must use -the explicit `google:` and `zai:` prefixes. +Anthropic. Unprefixed `grok-*` names infer xAI. Gemini, DeepSeek, and GLM models +must use the explicit `google:`, `deepseek:`, and `zai:` prefixes. ```elixir {:ok, model} = @@ -68,6 +69,15 @@ Google: ) ``` +DeepSeek: + +```elixir +{:ok, model} = + BeamWeaver.Models.init_chat_model("deepseek:deepseek-v4-flash", + reasoning_effort: :low + ) +``` + Z.ai: ```elixir @@ -113,6 +123,8 @@ Provider scope is intentionally narrow: - `BeamWeaver.OpenAI.EmbeddingModel` for OpenAI embeddings - `BeamWeaver.Anthropic.ChatModel` for Anthropic Messages API - `BeamWeaver.Google.ChatModel` for Gemini Developer API +- `BeamWeaver.DeepSeek.ChatModel` for DeepSeek Chat Completions +- `BeamWeaver.DeepSeek.ResponsesModel` for DeepSeek Responses - `BeamWeaver.Moonshot.ChatModel` for Moonshot/Kimi Chat Completions - `BeamWeaver.XAI.ChatModel` for xAI Responses API - `BeamWeaver.XAI.ChatCompletionsModel` for xAI Chat Completions @@ -121,7 +133,9 @@ Provider scope is intentionally narrow: - `BeamWeaver.Models.FakeChatModel` and `FakeEmbeddingModel` for tests Checked-in model profiles cover common OpenAI, Anthropic, Google Gemini, -Moonshot/Kimi, xAI, and Z.ai families. Moonshot chat supports +DeepSeek, Moonshot/Kimi, xAI, and Z.ai families. DeepSeek requires explicit +`deepseek:deepseek-v4-flash` or `deepseek:deepseek-v4-pro`; Chat Completions is +the default and Responses is currently Flash-only. Moonshot chat supports `moonshot:kimi-k3`, `moonshot:kimi-k2.7-code`, `moonshot:kimi-k2.7-code-highspeed`, `moonshot:kimi-k2.6`, and `moonshot:kimi-k2.5`. xAI chat defaults to @@ -162,6 +176,7 @@ Recommended starting points: | OpenAI GPT | `openai:gpt-5.6-sol`, `openai:gpt-5.6-terra`, `openai:gpt-5.6-luna`, `openai:gpt-5.4-mini` | | Anthropic Claude | `anthropic:claude-opus-5`, `anthropic:claude-sonnet-5`, `anthropic:claude-sonnet-4-6`, `anthropic:claude-opus-*`, `anthropic:claude-haiku-*` | | Google Gemini | `google:gemini-3.6-flash`, `google:gemini-3.5-flash-lite`, explicit `google:gemini-*` profiles | +| DeepSeek V4 | `deepseek:deepseek-v4-flash`, `deepseek:deepseek-v4-pro` | | Moonshot/Kimi | `moonshot:kimi-k3`, `moonshot:kimi-k2.7-code`, `moonshot:kimi-k2.7-code-highspeed`, `moonshot:kimi-k2.6`, `moonshot:kimi-k2.5` | | xAI Grok | `xai:grok-4.5`, `xai:grok-4.3`, `xai:grok-4.20-0309-reasoning` | | Z.ai GLM | `zai:glm-5.2` | @@ -680,6 +695,12 @@ BeamWeaver.Core.ChatModel.invoke(model, "Plan the migration.", ) ``` +DeepSeek Chat uses the same `thinking` and `reasoning_effort` shapes. DeepSeek +Responses accepts `reasoning: %{effort: ...}` and is selected with +`api: :responses`. V4 thinking requests must omit Chat `tool_choice`; set +thinking to disabled before sending an explicit choice. Forced function/custom +choices in Responses require `reasoning: %{effort: "none"}`. + Reasoning output is surfaced as content blocks or stream events when the underlying provider returns it. @@ -707,7 +728,7 @@ Prompt caching is provider-specific: controls for supported providers; Anthropic marks the static system prompt with `cache_control`. - Moonshot/Kimi supports `:prompt_cache_key`. -- Gemini and Z.ai cached-token usage is normalized when providers report it. +- Gemini, DeepSeek, and Z.ai cached-token usage is normalized when providers report it. - Usage metadata preserves cache-read/cache-write/cache-creation token details when providers return them. @@ -717,7 +738,7 @@ See [Prompt Caching](prompt_caching.md) for provider-specific examples. **Prompt Cache Scope** Prompt caching is not portable across providers. OpenAI, xAI, Anthropic, -Gemini, Moonshot/Kimi, and Z.ai expose different request fields, cache markers, +Gemini, DeepSeek, Moonshot/Kimi, and Z.ai expose different request fields, cache markers, and usage metadata. BeamWeaver keeps those controls at the provider boundary instead of inventing a universal cache wrapper that would hide important provider behavior. diff --git a/docs/partners.md b/docs/partners.md index 037340d..42694b8 100644 --- a/docs/partners.md +++ b/docs/partners.md @@ -18,6 +18,7 @@ streaming. | [OpenAI](partners/openai.md) | `BeamWeaver.OpenAI.*` | Yes | Yes | Yes | Yes | Function tools and Responses built-ins | JSON schema via Responses or Chat Completions | Text deltas, lifecycle events, reconstructed responses | Tokenizer/profile based | | [Anthropic](partners/anthropic.md) | `BeamWeaver.Anthropic.*` | Yes | No | No | No | Custom tools and Anthropic server tools | Tool/schema strategy through model calls | Text deltas, typed events, reconstructed messages | Anthropic count-tokens endpoint | | [Google](partners/google.md) | `BeamWeaver.Google.*` | Yes | No | No | No | Function declarations and Gemini built-ins | Gemini generation config schema | Text deltas, typed events, reconstructed messages | Gemini count-tokens endpoint | +| [DeepSeek](partners/deepseek.md) | `BeamWeaver.DeepSeek.*` | Yes | Yes | Flash only | No | Function tools, hosted web search, and Responses `apply_patch` | JSON object plus local validation in Chat; JSON Schema in Responses | Text/reasoning/tool deltas, named Responses events, reconstructed messages | Approximate fallback | | [Moonshot/Kimi](partners/moonshot.md) | `BeamWeaver.Moonshot.*` | Yes | Yes | No | No | OpenAI-compatible functions, K3 required/dynamic tools, and legacy Kimi `$web_search` | JSON object/schema request options | Text/reasoning/tool-call deltas, choice- or response-level usage, typed events, reconstructed messages | Moonshot estimate-token endpoint | | [xAI](partners/xai.md) | `BeamWeaver.XAI.*` | Yes | Yes | Yes | Yes | OpenAI-compatible function tools and xAI built-ins | JSON schema request options | Text deltas, typed events, reconstructed messages | Tokenizer/profile or approximate fallback | | [Z.ai](partners/zai.md) | `BeamWeaver.ZAI.*` | Yes | Yes | No | No | OpenAI-compatible function tools | JSON object mode plus schema instructions and local validation | Text/reasoning/tool-call deltas, usage chunks, reconstructed messages | Approximate fallback | @@ -35,6 +36,7 @@ output, stream useful events, and count enough tokens for context management. | OpenAI GPT | `openai:gpt-5.6-sol`, `openai:gpt-5.6-terra`, `openai:gpt-5.6-luna`, `openai:gpt-5.4-mini`, explicit `BeamWeaver.OpenAI.*` structs | Strong default | Custom function tools, Responses built-ins, raw Responses tool-result turns | Provider-native Responses or Chat Completions schema; tool strategy fallback at agent layer | Text, reasoning, tool-call lifecycle, reconstructed streamed responses | Tokenizer/profile based with approximate fallback | GPT-5.6 adds 1.05M context, `max` effort, pro mode, and persisted reasoning; hosted multi-agent/programmatic tool calling are not yet wrapped. | | Anthropic Claude | `anthropic:claude-opus-5`, `anthropic:claude-sonnet-5`, `anthropic:claude-fable-5`, `anthropic:claude-mythos-5`, `anthropic:claude-sonnet-4-6`, `anthropic:claude-opus-*`, `anthropic:claude-haiku-*` | Strong default | Custom tools plus Anthropic server tools through provider helpers | Anthropic output config plus BeamWeaver parsing/validation | Text, typed Anthropic stream envelopes, reconstructed messages | Anthropic count-tokens endpoint | Opus 5 defaults to adaptive thinking, supports the full effort ladder and server-side refusal fallback, and does not support web fetch; prompt caching and server tools are provider-specific. | | Google Gemini | `google:gemini-3.6-flash`, `google:gemini-3.5-flash-lite`, other explicit `google:gemini-*` profiles | Supported | Function declarations and Gemini built-ins | Gemini generation config schema | Text, typed Gemini events, reconstructed messages | Gemini count-tokens endpoint | Gemini identifiers must use the `google:` prefix; 3.5 Flash Cyber is not a public Gemini API model. | +| DeepSeek V4 | `deepseek:deepseek-v4-flash`, `deepseek:deepseek-v4-pro` | Supported | Function tools; Flash Responses adds hosted web search and `apply_patch` | Chat JSON object plus local validation; Flash Responses JSON Schema | Text/reasoning/tool chunks, typed events, reconstructed messages | Approximate fallback | Explicit `deepseek:` prefix required. Responses is currently Flash-only; both models support Chat, beta prefix/FIM, and Anthropic compatibility. | | Moonshot/Kimi | `moonshot:kimi-k3`, `moonshot:kimi-k2.7-code`, `moonshot:kimi-k2.7-code-highspeed`, `moonshot:kimi-k2.6`, `moonshot:kimi-k2.5` | Supported with Kimi constraints | OpenAI-compatible functions; K3 adds required choice and dynamic loading; legacy `$web_search` only where thinking can be disabled | JSON object/schema request options | Text, reasoning, tool-call chunks, usage chunks, reconstructed messages | Moonshot estimate-token endpoint | K3 has 1,048,576-token context/output limits, always reasons with `reasoning_effort: "max"`, and rejects K2 `thinking`; web search is currently being updated by Kimi. | | xAI Grok | `xai:grok-4.5`, `xai:grok-4.3`, `xai:grok-4.20-0309-reasoning`, explicit `BeamWeaver.XAI.*` structs | Supported | OpenAI-compatible functions and xAI built-ins | JSON schema request options | Text, reasoning/citation metadata, typed events, reconstructed messages | Tokenizer/profile or approximate fallback | Useful for coding, agentic work, and Grok-specific reasoning/citation behavior; provider metadata is normalized. | | Z.ai GLM | `zai:glm-5.2`, explicit `BeamWeaver.ZAI.ChatModel` structs | Supported | OpenAI-compatible functions with `tool_stream` for streamed arguments | JSON object mode plus BeamWeaver schema instructions and local validation | Text, reasoning, tool-call chunks, usage chunks, reconstructed messages | Approximate fallback | GLM identifiers must use `zai:`; only `glm-5.2` is enabled. Usage includes cached-input and reasoning-token details for cost metadata. | diff --git a/docs/partners/deepseek.md b/docs/partners/deepseek.md new file mode 100644 index 0000000..a9ea261 --- /dev/null +++ b/docs/partners/deepseek.md @@ -0,0 +1,232 @@ +# BeamWeaver DeepSeek + +BeamWeaver includes a first-class DeepSeek provider for the native Chat +Completions and Responses APIs, plus raw clients for every API surface that +DeepSeek currently publishes. + +This guide reflects the DeepSeek documentation and live API behavior checked +on 2026-08-02. + +## Models + +Use explicit provider-prefixed identifiers: + +- `deepseek:deepseek-v4-flash` +- `deepseek:deepseek-v4-pro` + +Both models have a 1,048,576-token context window and a maximum output of +393,216 tokens. Chat Completions supports both models. The Responses API +currently supports only `deepseek-v4-flash`; BeamWeaver rejects a Pro +Responses request before making an HTTP request. + +The retired `deepseek-chat` and `deepseek-reasoner` identifiers are not aliases +for the V4 models. BeamWeaver reports them as unsupported so an application +cannot silently change model behavior. + +## Configuration + +```elixir +config :beam_weaver, + deepseek: [ + api_key: System.fetch_env!("DEEPSEEK_API_KEY") + ] +``` + +`config/runtime.exs` reads `DEEPSEEK_API_KEY`. The stable, beta, and Anthropic +base URLs can also be overridden independently for testing or a compatible +gateway. The default request timeout follows the rest of BeamWeaver's provider +clients: 15 seconds. Set a larger `:timeout` for long reasoning requests. + +## Chat Completions + +Chat Completions is the default high-level API: + +```elixir +{:ok, model} = + BeamWeaver.Models.init_chat_model("deepseek:deepseek-v4-flash", + thinking: %{type: :enabled}, + reasoning_effort: :low, + max_tokens: 1_024, + timeout: 120_000 + ) + +{:ok, message} = + BeamWeaver.Core.ChatModel.invoke(model, [ + BeamWeaver.Core.Message.user("Explain OTP supervision in three sentences.") + ]) +``` + +Thinking output is normalized into reasoning content blocks. When an assistant +tool call is sent back with a tool result, BeamWeaver also replays the +provider's `reasoning_content`, as required by DeepSeek's multi-turn tool +contract. + +Current V4 behavior rejects any explicit Chat `tool_choice` while thinking is +active, including `none`, `auto`, and `required`. Omit `tool_choice` to let a +thinking model select from declared tools, or set +`thinking: %{type: "disabled"}` before sending an explicit choice. For +Responses, forced function/custom choices require +`reasoning: %{effort: "none"}`; automatic choice and hosted web search remain +available with reasoning. + +### Beta endpoint + +DeepSeek's beta endpoint is an alternate, unstable API namespace at +`https://api.deepseek.com/beta`. It enables features that DeepSeek has not yet +promoted to its stable endpoint. It is not a different model, a replacement for +the stable API, or an `api:` value in BeamWeaver. The supported `api:` values +remain `:chat_completions` and `:responses`. + +BeamWeaver uses the stable Chat endpoint by default and automatically routes a +request to `https://api.deepseek.com/beta/chat/completions` when it contains +either: + +- a final assistant message with prefix completion enabled; or +- strict function tools. + +To try the beta Chat endpoint for any high-level request, pass `beta: true` as +an invocation option. The same option works with `invoke`, `stream`, +`stream_response`, and the typed-event stream functions: + +```elixir +{:ok, message} = + BeamWeaver.Core.ChatModel.invoke(model, messages, beta: true) +``` + +The raw Chat client accepts the same per-call option: + +```elixir +{:ok, response} = + BeamWeaver.DeepSeek.Client.chat_completions(client, body, beta: true) +``` + +To route every Chat request from one model through beta, set its endpoint +explicitly: + +```elixir +model = + BeamWeaver.DeepSeek.chat_model( + endpoint: "https://api.deepseek.com/beta/chat/completions" + ) +``` + +`beta_endpoint:` and `DEEPSEEK_BETA_BASE_URL` only change where beta-selected +requests are sent, which is useful for gateways and tests. They do not activate +beta for otherwise stable requests. An explicit per-call `endpoint:` takes +precedence over `beta: true` and automatic feature detection. + +FIM completion already uses `https://api.deepseek.com/beta/completions`, so its +raw client methods do not need `beta: true`. The Responses API has no beta route +in BeamWeaver and continues to use the stable `/responses` endpoint. + +DeepSeek Chat supports JSON object mode, not native JSON Schema mode. +Schema-shaped BeamWeaver structured-output requests therefore enable +`json_object`, add an explicit JSON/schema instruction, and validate the final +object locally. + +## Responses API + +Select the stateless Responses API explicitly: + +```elixir +{:ok, model} = + BeamWeaver.Models.init_chat_model("deepseek:deepseek-v4-flash", + api: :responses, + timeout: 120_000 + ) +``` + +Responses supports native JSON Schema output, function tools, server-side web +search, and the custom `apply_patch` tool used by DeepSeek's Codex integration. +DeepSeek does not store Responses or conversations: send the complete history +on every turn. BeamWeaver rejects stateful response/conversation parameters and +unsupported image, audio, video, or file inputs instead of allowing the server +to replace them with placeholder text. + +Hosted web search can complete without an assistant message. The normalized +result preserves reasoning, function-call, web-search, failure, and unknown +output items in response metadata rather than assuming every successful +response contains text. + +## Raw Client + +`BeamWeaver.DeepSeek.Client` exposes map-in/map-out access to all current +DeepSeek endpoints: + +- `chat_completions/3`, streaming deltas, typed events, and collected response +- `responses/3`, streaming deltas/events, and collected response +- `completions/3` for beta FIM, including lazy and collected streams +- `models/2` +- `balance/2` +- `anthropic_messages/3`, including lazy and collected streams + +Native endpoints use Bearer authentication. The Anthropic-compatible endpoint +uses `x-api-key` at `https://api.deepseek.com/anthropic/v1/messages` and reuses +BeamWeaver's Anthropic message and stream translation without exposing an +unsupported count-tokens call. + +The Anthropic compatibility API accepts canonical DeepSeek IDs. It also maps +Claude model names: `claude-opus*` to Pro and `claude-sonnet*`/`claude-haiku*` +to Flash. Other unsupported names are mapped by the server to Flash; the raw +client preserves that server behavior. + +## Streaming And Headers + +Chat and FIM return data-only SSE terminated by `[DONE]`. Responses and +Anthropic use named events and their own terminal events. All BeamWeaver lazy +stream methods remain lazy and do not buffer the potentially large response. +Collected stream methods necessarily reconstruct the full response in memory. + +DeepSeek's `x-ds-trace-id` header is normalized into request metadata. +Synchronous and collected calls accept `include_response_headers: true` to +retain both a normalized header map and the original ordered, +duplicate-preserving header list. Lazy calls accept an `on_response` callback +for transport status and headers without buffering the stream. Missing +rate-limit headers are valid. + +## Usage And Pricing + +DeepSeek reports prompt cache hits and misses. BeamWeaver normalizes cached and +uncached input, output, total, and reasoning token details, then calculates +cost from the model profile. Reasoning tokens are already included in output +tokens and are never charged twice. + +Current prices per one million tokens: + +| Model | Cached input | Uncached input | Output | +| --- | ---: | ---: | ---: | +| `deepseek-v4-flash` | $0.0028 | $0.14 | $0.28 | +| `deepseek-v4-pro` | $0.003625 | $0.435 | $0.87 | + +The published account concurrency limits are 2,500 for Flash and 500 for Pro. +They are profile metadata, not an in-process limiter, because DeepSeek enforces +them at account scope across all API keys. + +DeepSeek has announced a future 2x peak-hours multiplier but has not announced +its effective date. BeamWeaver records the policy in metadata and documentation +without applying it to calculated costs yet. + +## Errors And Retries + +DeepSeek HTTP failures are normalized into authentication, insufficient +balance, invalid request/parameter, rate limit, server error, and overload +categories. JSON errors are decoded even when the server labels the body as +`application/octet-stream`. + +The client does not retry automatically. Apply BeamWeaver retry or fallback +middleware at the application boundary where idempotency and provider fallback +policy are explicit. + +## References + +- [DeepSeek models and pricing](https://api-docs.deepseek.com/quick_start/pricing/) +- [Chat Completions API](https://api-docs.deepseek.com/api/create-chat-completion/) +- [Chat Prefix Completion](https://api-docs.deepseek.com/guides/chat_prefix_completion/) +- [Strict Function Calling](https://api-docs.deepseek.com/guides/tool_calls/) +- [Responses API](https://api-docs.deepseek.com/api/create-response/) +- [FIM Completion API](https://api-docs.deepseek.com/api/create-completion/) +- [Anthropic compatibility](https://api-docs.deepseek.com/guides/anthropic_api/) +- [Rate limit and isolation](https://api-docs.deepseek.com/quick_start/rate_limit/) + +The opt-in full-surface runner is +[`scripts/capture_deepseek_live.exs`](../../scripts/capture_deepseek_live.exs). diff --git a/docs/partners/openai.md b/docs/partners/openai.md index 9949e0c..883315d 100644 --- a/docs/partners/openai.md +++ b/docs/partners/openai.md @@ -112,18 +112,22 @@ three profiles expose a 1.05M-token context window, 128K maximum output, text and image input, Responses and Chat Completions, function calling, structured output, streaming, and the current OpenAI built-in tool catalog. -Standard prices per million tokens are: +Standard short-context prices per million tokens, effective July 30, 2026, are: | Model | Input | Cached input | Cache write | Output | | --- | ---: | ---: | ---: | ---: | | `gpt-5.6-sol` | $5.00 | $0.50 | $6.25 | $30.00 | -| `gpt-5.6-terra` | $2.50 | $0.25 | $3.125 | $15.00 | -| `gpt-5.6-luna` | $1.00 | $0.10 | $1.25 | $6.00 | +| `gpt-5.6-terra` | $2.00 | $0.20 | $2.50 | $12.00 | +| `gpt-5.6-luna` | $0.20 | $0.02 | $0.25 | $1.20 | Requests above 272K input tokens use OpenAI's higher-context rates: 2x input and 1.5x output for the full request. GPT-5.6 cache writes cost 1.25x uncached input, cache reads receive the 90% discount, and the current cache TTL is 30 minutes. Eligible regional-processing endpoints add OpenAI's 10% uplift. +Batch and Flex processing cost half the Standard rate. Fast mode costs twice +the Standard short-context rate and replaces Priority Processing; OpenAI +continues to accept both `service_tier: :fast` and `service_tier: :priority`. +See [OpenAI API pricing](https://developers.openai.com/api/docs/pricing). The existing `reasoning` request option carries GPT-5.6 controls without a separate model type: diff --git a/docs/prompt_caching.md b/docs/prompt_caching.md index 09b7e80..662972a 100644 --- a/docs/prompt_caching.md +++ b/docs/prompt_caching.md @@ -255,6 +255,18 @@ model = BeamWeaver does not create or expire Gemini cached-content resources for you in this release. +## DeepSeek + +DeepSeek performs automatic context caching for repeated prompt prefixes; it +does not expose a BeamWeaver cache-key option. Chat Completions reports +`prompt_cache_hit_tokens` and `prompt_cache_miss_tokens`. Responses reports +`input_tokens_details.cached_tokens`. + +BeamWeaver normalizes both APIs to `input_token_details.cache_read` and derives +uncached input without counting the same prompt token twice. The normalized +usage also includes cached and uncached input cost at the selected V4 model's +profile rates. + ## Z.ai GLM Z.ai does not expose a BeamWeaver cache key option in this release. When Z.ai diff --git a/docs/structured_output.md b/docs/structured_output.md index 11b3f3c..10ab939 100644 --- a/docs/structured_output.md +++ b/docs/structured_output.md @@ -129,10 +129,14 @@ Strategy values are atoms. Public Elixir config rejects string aliases such as ## Provider Strategy Provider strategy uses the model provider's native structured-output API. -OpenAI, Anthropic, Google, xAI, and Z.ai provider adapters accept structured +OpenAI, Anthropic, Google, DeepSeek, xAI, and Z.ai provider adapters accept structured output request options, and agent auto-selection uses the model profile's `:structured_output` capability. +DeepSeek Chat exposes JSON object mode, so BeamWeaver injects the schema as an +explicit instruction and validates the returned object locally. DeepSeek Flash +Responses exposes native JSON Schema format. Pro cannot use Responses yet. + ```elixir response_schema MyApp.ContactInfo, name: "contact_info", diff --git a/examples/deep_agent_composition.exs b/examples/deep_agent_composition.exs index c33ee42..b8eb377 100644 --- a/examples/deep_agent_composition.exs +++ b/examples/deep_agent_composition.exs @@ -13,6 +13,7 @@ defmodule BeamWeaver.Examples.DeepAgentComposition.Helper do name("helper") description("Handle isolated helper work.") model(Support.model()) + model_opts(timeout: 120_000) system_prompt("Return the helper result in one sentence.") end @@ -22,6 +23,8 @@ defmodule BeamWeaver.Examples.DeepAgentComposition.Agent do name("composed_deep_agent") description("A deep agent built from normal BeamWeaver capabilities.") model(Support.model()) + model_opts(timeout: 120_000) + recursion_limit(32) filesystem(State.new()) compact_conversation(true) @@ -35,14 +38,19 @@ defmodule BeamWeaver.Examples.DeepAgentComposition.Agent do use ModelRetry, max_attempts: 2, retry_on: :transient end - system_prompt( - "Plan with write_todos, delegate isolated work to the helper, and use composed capabilities when useful." - ) + system_prompt("Plan once, delegate one isolated task to the helper, then answer without repeating tools.") end {:ok, %{messages: messages}} = - BeamWeaver.Examples.DeepAgentComposition.Agent.invoke(%{ - messages: [Message.user("Show how you compose planning and a helper subagent for a simple task.")] - }) + BeamWeaver.Examples.DeepAgentComposition.Agent.invoke( + %{ + messages: [ + Message.user( + "Create a two-item plan, delegate 7 * 6 to the helper exactly once, then give the result. Call each tool at most once." + ) + ] + }, + run_timeout: 240_000 + ) messages |> List.last() |> Message.text() |> IO.puts() diff --git a/examples/deepagents/support/support.exs b/examples/deepagents/support/support.exs index 66b2300..72fd08c 100644 --- a/examples/deepagents/support/support.exs +++ b/examples/deepagents/support/support.exs @@ -8,18 +8,18 @@ defmodule BeamWeaver.Examples.DeepAgents.Support do alias BeamWeaver.ExecutionPolicy alias BeamWeaver.Examples.Support - @node_timeout 60_000 + @node_timeout 120_000 @concise_suffix """ - Complete the user's request with one concise sentence. Do not call tools unless - absolutely necessary. + This is a live provider smoke run. Do not call tools. Answer the user's request + directly in one concise sentence using the context already provided. """ def create(opts) do opts = opts |> add_concise_prompt() - |> maybe_disable_visible_tools() + |> disable_visible_tools() |> Keyword.put_new(:model_opts, default_model_opts()) case Agent.build(opts) do @@ -30,15 +30,8 @@ defmodule BeamWeaver.Examples.DeepAgents.Support do def model, do: Support.model() - defp default_model_opts do - base = [max_tokens: 256, max_output_tokens: 256, timeout: @node_timeout] - - if String.starts_with?(Support.model_id(), "zai:") do - base - else - Keyword.put(base, :tool_choice, "none") - end - end + defp default_model_opts, + do: [max_tokens: 2_048, max_output_tokens: 2_048, timeout: @node_timeout] defp add_concise_prompt(opts) do Keyword.update(opts, :system_prompt, @concise_suffix, fn @@ -47,14 +40,10 @@ defmodule BeamWeaver.Examples.DeepAgents.Support do end) end - defp maybe_disable_visible_tools(opts) do - if String.starts_with?(Support.model_id(), "zai:") do - Keyword.update(opts, :middleware, [ToolSelection.new(allow: [])], fn middleware -> - List.wrap(middleware) ++ [ToolSelection.new(allow: [])] - end) - else - opts - end + defp disable_visible_tools(opts) do + Keyword.update(opts, :middleware, [ToolSelection.new(allow: [])], fn middleware -> + List.wrap(middleware) ++ [ToolSelection.new(allow: [])] + end) end defp extend_timeout(agent), do: update_in(agent.compiled, &extend_compiled_timeout/1) diff --git a/examples/deepseek.exs b/examples/deepseek.exs new file mode 100644 index 0000000..72603ab --- /dev/null +++ b/examples/deepseek.exs @@ -0,0 +1,56 @@ +alias BeamWeaver.Core.ChatModel +alias BeamWeaver.Core.Message +alias BeamWeaver.DeepSeek +alias BeamWeaver.DeepSeek.Client + +timeout = 120_000 + +chat = DeepSeek.chat_model(model: "deepseek-v4-flash", timeout: timeout) + +{:ok, chat_message} = + ChatModel.invoke(chat, [Message.user("Reply with exactly: hello from DeepSeek")], thinking: %{type: "disabled"}) + +IO.puts("Chat Completions: #{Message.text(chat_message)}") + +responses = DeepSeek.responses_model(model: "deepseek-v4-flash", timeout: timeout) + +{:ok, responses_message} = + ChatModel.invoke(responses, [Message.user("Reply with exactly: hello from Responses")]) + +IO.puts("Responses: #{Message.text(responses_message)}") + +client = Client.new(timeout: timeout) + +{:ok, fim} = + Client.completions(client, %{ + "model" => "deepseek-v4-flash", + "prompt" => "def hello do\n ", + "suffix" => "\nend", + "max_tokens" => 32 + }) + +IO.puts("FIM: #{get_in(fim, ["choices", Access.at(0), "text"])}") + +{:ok, anthropic} = + Client.anthropic_messages(client, %{ + "model" => "deepseek-v4-flash", + "max_tokens" => 64, + "messages" => [%{"role" => "user", "content" => "Reply with exactly: hello from Anthropic"}] + }) + +anthropic_text = + anthropic + |> Map.get("content", []) + |> Enum.find_value("", fn + %{"type" => "text", "text" => text} -> text + _block -> nil + end) + +IO.puts("Anthropic compatibility: #{anthropic_text}") + +{:ok, models} = Client.models(client) +IO.puts("Models endpoint returned #{length(models["data"] || [])} models") + +# `Client.balance/2` is also available, but this example deliberately avoids +# printing account-specific values. See scripts/capture_deepseek_live.exs for +# the guarded, sanitized full-surface validation workflow. diff --git a/lib/beam_weaver/adapters/ecto_postgres.ex b/lib/beam_weaver/adapters/ecto_postgres.ex index 3a40ef2..f8f9fdd 100644 --- a/lib/beam_weaver/adapters/ecto_postgres.ex +++ b/lib/beam_weaver/adapters/ecto_postgres.ex @@ -8,11 +8,11 @@ defmodule BeamWeaver.Adapters.EctoPostgres do @spec transaction(module(), (-> term())) :: term() | {:error, Error.t()} def transaction(repo, fun) when is_function(fun, 0) do - if is_atom(repo) and function_exported?(repo, :transaction, 1) do - case repo.transaction(fn -> + if is_atom(repo) and function_exported?(repo, :transact, 1) do + case repo.transact(fn -> case fun.() do - {:error, reason} -> repo.rollback(reason) - other -> other + {:error, reason} -> {:error, reason} + other -> {:ok, other} end end) do {:ok, :ok} -> :ok diff --git a/lib/beam_weaver/agent/model_resolver.ex b/lib/beam_weaver/agent/model_resolver.ex index cf8419e..a873ce3 100644 --- a/lib/beam_weaver/agent/model_resolver.ex +++ b/lib/beam_weaver/agent/model_resolver.ex @@ -116,6 +116,9 @@ defmodule BeamWeaver.Agent.ModelResolver do module == BeamWeaver.ZAI.ChatModel -> "zai" + module in [BeamWeaver.DeepSeek.ChatModel, BeamWeaver.DeepSeek.ResponsesModel] -> + "deepseek" + module == BeamWeaver.Models.FakeChatModel -> "fake" diff --git a/lib/beam_weaver/anthropic/chat_model/request_builder.ex b/lib/beam_weaver/anthropic/chat_model/request_builder.ex index c215ab6..4129cba 100644 --- a/lib/beam_weaver/anthropic/chat_model/request_builder.ex +++ b/lib/beam_weaver/anthropic/chat_model/request_builder.ex @@ -492,7 +492,7 @@ defmodule BeamWeaver.Anthropic.ChatModel.RequestBuilder do defp task_budget?(%{"task_budget" => task_budget}) when not is_nil(task_budget), do: true defp task_budget?(_output_config), do: false - defp tool_changes?(messages) when is_list(messages) do + defp tool_changes?(messages) do Enum.any?(messages, fn %{"content" => content} when is_list(content) -> Enum.any?(content, fn @@ -505,8 +505,6 @@ defmodule BeamWeaver.Anthropic.ChatModel.RequestBuilder do end) end - defp tool_changes?(_messages), do: false - defp normalize_fallbacks(fallbacks) when fallbacks in [nil, []], do: nil defp normalize_fallbacks(fallbacks) when is_list(fallbacks), do: Options.normalize_option_list(fallbacks) defp normalize_fallbacks(fallbacks), do: Options.normalize_value(fallbacks) diff --git a/lib/beam_weaver/core/chat_model.ex b/lib/beam_weaver/core/chat_model.ex index 42d59bc..8045555 100644 --- a/lib/beam_weaver/core/chat_model.ex +++ b/lib/beam_weaver/core/chat_model.ex @@ -544,6 +544,8 @@ defmodule BeamWeaver.Core.ChatModel do ["BeamWeaver", "XAI", "ChatCompletionsModel"] -> [:xai_chat_completions] ["BeamWeaver", "Moonshot", "ChatModel"] -> [:moonshot] ["BeamWeaver", "ZAI", "ChatModel"] -> [:zai] + ["BeamWeaver", "DeepSeek", "ChatModel"] -> [:deepseek_chat_completions] + ["BeamWeaver", "DeepSeek", "ResponsesModel"] -> [:deepseek_responses] ["BeamWeaver", "Models", "FakeChatModel"] -> [:all] _other -> [:generic] end @@ -559,6 +561,8 @@ defmodule BeamWeaver.Core.ChatModel do defp provider_params(:xai_chat_completions), do: ProfileParams.xai_chat_completions() defp provider_params(:moonshot), do: ProfileParams.moonshot() defp provider_params(:zai), do: ProfileParams.zai() + defp provider_params(:deepseek_chat_completions), do: ProfileParams.deepseek_chat_completions() + defp provider_params(:deepseek_responses), do: ProfileParams.deepseek_responses() defp provider_params(:generic), do: @generic_model_param_keys defp provider_params(:all) do @@ -570,7 +574,9 @@ defmodule BeamWeaver.Core.ChatModel do ProfileParams.xai_responses(), ProfileParams.xai_chat_completions(), ProfileParams.moonshot(), - ProfileParams.zai() + ProfileParams.zai(), + ProfileParams.deepseek_chat_completions(), + ProfileParams.deepseek_responses() ] |> List.flatten() end @@ -646,6 +652,7 @@ defmodule BeamWeaver.Core.ChatModel do "XAI" in parts -> "xai" "Moonshot" in parts -> "moonshot" "ZAI" in parts -> "zai" + "DeepSeek" in parts -> "deepseek" parts == ["BeamWeaver", "Models", "FakeChatModel"] -> "fake" true -> nil end diff --git a/lib/beam_weaver/deepseek.ex b/lib/beam_weaver/deepseek.ex new file mode 100644 index 0000000..a6de148 --- /dev/null +++ b/lib/beam_weaver/deepseek.ex @@ -0,0 +1,56 @@ +defmodule BeamWeaver.DeepSeek do + @moduledoc """ + DeepSeek provider namespace. + """ + + alias BeamWeaver.Config + alias BeamWeaver.DeepSeek.ChatModel + alias BeamWeaver.DeepSeek.ResponsesModel + alias BeamWeaver.DeepSeek.Tools + + @default_base_url "https://api.deepseek.com" + + @doc "Builds the default DeepSeek Chat Completions model." + @spec chat_model(keyword() | map()) :: ChatModel.t() + def chat_model(opts \\ []) do + opts + |> normalize_opts() + |> provider_opts() + |> ChatModel.new() + end + + @doc "Builds an explicit DeepSeek Chat Completions model." + @spec chat_completions_model(keyword() | map()) :: ChatModel.t() + def chat_completions_model(opts \\ []), do: chat_model(opts) + + @doc "Builds an explicit DeepSeek Responses API model." + @spec responses_model(keyword() | map()) :: ResponsesModel.t() + def responses_model(opts \\ []) do + opts + |> normalize_opts() + |> provider_opts() + |> ResponsesModel.new() + end + + @doc "Returns DeepSeek tool declaration helpers." + @spec tools() :: module() + def tools, do: Tools + + defp normalize_opts(opts) when is_map(opts), do: Map.to_list(opts) + defp normalize_opts(opts), do: opts + + defp provider_opts(opts) do + opts + |> Keyword.delete(:api) + |> Keyword.put_new(:api_key, Config.get([:deepseek, :api_key])) + |> Keyword.put_new(:base_url, configured_base_url(opts)) + end + + defp configured_base_url(opts) do + if Keyword.has_key?(opts, :base_url) do + Keyword.fetch!(opts, :base_url) || @default_base_url + else + Config.get([:deepseek, :base_url], @default_base_url) + end + end +end diff --git a/lib/beam_weaver/deepseek/chat_model.ex b/lib/beam_weaver/deepseek/chat_model.ex new file mode 100644 index 0000000..d0a9aab --- /dev/null +++ b/lib/beam_weaver/deepseek/chat_model.ex @@ -0,0 +1,153 @@ +defmodule BeamWeaver.DeepSeek.ChatModel do + @moduledoc """ + DeepSeek OpenAI-compatible Chat Completions model. + """ + + alias BeamWeaver.Core.LanguageModel + alias BeamWeaver.DeepSeek.Client + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.DeepSeek.Messages + alias BeamWeaver.DeepSeek.Options + alias BeamWeaver.Models.InvocationMetadata + alias BeamWeaver.Provider.ChatModel.Options, as: ChatOptions + alias BeamWeaver.Provider.StructuredOutput + + @default_model "deepseek-v4-flash" + @default_base_url "https://api.deepseek.com" + @default_endpoint @default_base_url <> "/chat/completions" + @default_beta_endpoint @default_base_url <> "/beta/chat/completions" + + # credo:disable-for-next-line Credo.Check.Warning.StructFieldAmount + defstruct model: @default_model, + base_url: @default_base_url, + endpoint: @default_endpoint, + beta_endpoint: @default_beta_endpoint, + api_key: nil, + default_headers: [], + model_kwargs: %{}, + thinking: nil, + reasoning_effort: nil, + temperature: nil, + max_tokens: nil, + max_completion_tokens: nil, + max_output_tokens: nil, + top_p: nil, + stop: nil, + response_format: nil, + structured_output: nil, + tool_choice: nil, + logprobs: nil, + top_logprobs: nil, + user_id: nil, + stream_usage: true, + streaming: false, + profile: nil, + tokenizer: nil, + param_policy: nil, + include_response_headers: false, + transport: nil, + transport_opts: [], + timeout: 15_000 + + @type t :: %__MODULE__{} + + use BeamWeaver.Provider.ChatModel + + @spec new(keyword() | map()) :: t() + def new(opts \\ []) do + opts = ChatOptions.keyword_options(opts) + opts = normalize_endpoint_opts(opts) + model = Keyword.get(opts, :model, @default_model) + profile = ChatOptions.profile_option(opts, :deepseek, model) + + struct!( + __MODULE__, + opts + |> Keyword.put(:model, model) + |> Keyword.put(:profile, profile) + ) + end + + @spec request_body(t(), [BeamWeaver.Core.Message.t()], keyword()) :: + {:ok, map()} | {:error, Error.t()} + def request_body(%__MODULE__{} = model, messages, opts \\ []), + do: Options.to_body(model, messages, opts) + + def count_tokens(%__MODULE__{} = model, input, opts \\ []) do + case model.tokenizer || BeamWeaver.Models.tokenizer_for(model) do + {:ok, tokenizer} -> LanguageModel.count_tokens({:tokenizer, tokenizer}, input, opts) + tokenizer when not is_nil(tokenizer) -> LanguageModel.count_tokens({:tokenizer, tokenizer}, input, opts) + _missing -> {:ok, LanguageModel.count_tokens_approximately(input)} + end + end + + defp normalize_endpoint_opts(opts) do + base_url = Keyword.get(opts, :base_url, @default_base_url) || @default_base_url + + client_opts = + [base_url: base_url] + |> maybe_put_client_endpoint(:endpoint, Keyword.get(opts, :endpoint)) + |> maybe_put_client_endpoint( + :beta_chat_completions_endpoint, + Keyword.get(opts, :beta_endpoint) + ) + + client = Client.new(client_opts) + + opts + |> Keyword.put(:base_url, base_url) + |> Keyword.put_new(:endpoint, client.chat_completions_endpoint) + |> Keyword.put_new(:beta_endpoint, client.beta_chat_completions_endpoint) + end + + defp maybe_put_client_endpoint(opts, _key, nil), do: opts + defp maybe_put_client_endpoint(opts, key, value), do: Keyword.put(opts, key, value) + + defp client(%__MODULE__{} = model) do + Client.new( + base_url: model.base_url, + endpoint: model.endpoint, + chat_completions_endpoint: model.endpoint, + beta_chat_completions_endpoint: model.beta_endpoint, + api_key: model.api_key, + default_headers: model.default_headers || [], + transport: model.transport, + transport_opts: model.transport_opts, + timeout: model.timeout + ) + end + + defp model_stream_metadata(%__MODULE__{} = model, body, opts) do + model + |> InvocationMetadata.provider(:deepseek, body, opts, :chat_completions) + |> InvocationMetadata.to_metadata_map() + end + + defp runtime_adapter do + %ChatRuntime.Adapter{ + request: &request_body/3, + invoke: fn model, body, opts -> Client.chat_completions(client(model), body, opts) end, + stream: fn model, body, opts -> + Client.chat_completions_stream(client(model), body, opts) + end, + stream_response: fn model, body, opts -> + Client.chat_completions_stream_response(client(model), body, opts) + end, + stream_events: fn model, body, opts -> + Client.chat_completions_stream_typed_events(client(model), body, opts) + end, + decode: fn + %BeamWeaver.Core.Message{} = message, _opts -> {:ok, message} + response, _opts -> Messages.chat_response_to_message(response) + end, + parse: fn model, message, opts -> + StructuredOutput.maybe_parse(message, Options.response_parse_opts(model, opts), + error_module: Error, + provider_name: "DeepSeek" + ) + end, + metadata: &model_stream_metadata/3, + source: :deepseek_chat_completions + } + end +end diff --git a/lib/beam_weaver/deepseek/client.ex b/lib/beam_weaver/deepseek/client.ex new file mode 100644 index 0000000..5f6f849 --- /dev/null +++ b/lib/beam_weaver/deepseek/client.ex @@ -0,0 +1,493 @@ +defmodule BeamWeaver.DeepSeek.Client do + @moduledoc """ + Raw client for DeepSeek's native, OpenAI-compatible, and Anthropic-compatible APIs. + """ + + alias BeamWeaver.Config + alias BeamWeaver.DeepSeek.Client.ResponseDecoder + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.Provider.OpenAICompatibleClient + alias BeamWeaver.Provider.OpenAICompatibleStreaming + alias BeamWeaver.Provider.Options, as: ProviderOptions + alias BeamWeaver.Transport.Request + + @default_base_url "https://api.deepseek.com" + @default_timeout 15_000 + + defstruct base_url: @default_base_url, + beta_base_url: @default_base_url <> "/beta", + anthropic_base_url: @default_base_url <> "/anthropic", + endpoint: @default_base_url <> "/chat/completions", + chat_completions_endpoint: @default_base_url <> "/chat/completions", + beta_chat_completions_endpoint: @default_base_url <> "/beta/chat/completions", + responses_endpoint: @default_base_url <> "/responses", + completions_endpoint: @default_base_url <> "/beta/completions", + models_endpoint: @default_base_url <> "/models", + balance_endpoint: @default_base_url <> "/user/balance", + anthropic_messages_endpoint: @default_base_url <> "/anthropic/v1/messages", + api_key: nil, + default_headers: [], + transport: nil, + transport_opts: [], + timeout: @default_timeout + + @type t :: %__MODULE__{} + + @client_fields [ + :base_url, + :beta_base_url, + :anthropic_base_url, + :endpoint, + :chat_completions_endpoint, + :beta_chat_completions_endpoint, + :responses_endpoint, + :completions_endpoint, + :models_endpoint, + :balance_endpoint, + :anthropic_messages_endpoint, + :api_key, + :default_headers, + :transport, + :transport_opts, + :timeout + ] + + @doc "Builds a DeepSeek client from keyword options or a map." + @spec new(keyword() | map()) :: t() + def new(opts \\ []) do + opts = if is_map(opts), do: Map.to_list(opts), else: opts + configured_base_url = configured_url(opts, :base_url, [:deepseek, :base_url], @default_base_url) + api_root = api_root(configured_base_url) + base_url = stable_base_url(configured_base_url, api_root) + + beta_base_url = + configured_url(opts, :beta_base_url, [:deepseek, :beta_base_url], endpoint(api_root, "beta")) + + anthropic_base_url = + configured_url( + opts, + :anthropic_base_url, + [:deepseek, :anthropic_base_url], + endpoint(api_root, "anthropic") + ) + + default_chat_endpoint = endpoint(base_url, "chat/completions") + + chat_completions_endpoint = + Keyword.get( + opts, + :chat_completions_endpoint, + Keyword.get(opts, :endpoint, default_chat_endpoint) + ) + + beta_chat_completions_endpoint = + cond do + Keyword.has_key?(opts, :beta_chat_completions_endpoint) -> + Keyword.fetch!(opts, :beta_chat_completions_endpoint) + + (Keyword.has_key?(opts, :chat_completions_endpoint) or + Keyword.has_key?(opts, :endpoint)) and + chat_completions_endpoint != default_chat_endpoint -> + chat_completions_endpoint + + true -> + endpoint(beta_base_url, "chat/completions") + end + + struct(__MODULE__, + base_url: base_url, + beta_base_url: beta_base_url, + anthropic_base_url: anthropic_base_url, + endpoint: Keyword.get(opts, :endpoint, chat_completions_endpoint), + chat_completions_endpoint: chat_completions_endpoint, + beta_chat_completions_endpoint: beta_chat_completions_endpoint, + responses_endpoint: Keyword.get(opts, :responses_endpoint, endpoint(base_url, "responses")), + completions_endpoint: Keyword.get(opts, :completions_endpoint, endpoint(beta_base_url, "completions")), + models_endpoint: Keyword.get(opts, :models_endpoint, endpoint(base_url, "models")), + balance_endpoint: Keyword.get(opts, :balance_endpoint, endpoint(base_url, "user/balance")), + anthropic_messages_endpoint: + Keyword.get( + opts, + :anthropic_messages_endpoint, + endpoint(anthropic_base_url, "v1/messages") + ), + api_key: Config.option(opts, :api_key, [:deepseek, :api_key]), + default_headers: Keyword.get(opts, :default_headers, []), + transport: ProviderOptions.default_transport(Keyword.get(opts, :transport)), + transport_opts: Keyword.get(opts, :transport_opts, []), + timeout: Keyword.get(opts, :timeout, @default_timeout) + ) + end + + @spec chat_completions(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def chat_completions(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, chat_completions_endpoint(client, body, opts)) + + client + |> do_request(body, opts) + |> ResponseDecoder.json(opts) + end + + @spec chat_completions_stream(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def chat_completions_stream(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, chat_completions_endpoint(client, body, opts)) + + do_stream(client, body, opts, &OpenAICompatibleStreaming.text_deltas/1) + end + + @spec chat_completions_stream_response(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def chat_completions_stream_response(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, chat_completions_endpoint(client, body, opts)) + + do_stream_collect(client, body, opts, &ResponseDecoder.chat_completions_stream_response(&1, opts)) + end + + @spec chat_completions_stream_typed_events(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def chat_completions_stream_typed_events(client_or_opts, body, opts \\ []) + when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, chat_completions_endpoint(client, body, opts)) + + do_stream(client, body, opts, &deepseek_chat_typed_events/1) + end + + @spec responses(t() | keyword(), map(), keyword()) :: {:ok, map()} | {:error, Error.t()} + def responses(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.responses_endpoint) + + client + |> do_request(body, opts) + |> ResponseDecoder.json(opts) + end + + @spec responses_stream(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def responses_stream(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.responses_endpoint) + + do_stream(client, body, opts, &BeamWeaver.OpenAI.Streaming.text_deltas/1) + end + + @spec responses_stream_response(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def responses_stream_response(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.responses_endpoint) + + do_stream_collect(client, body, opts, &ResponseDecoder.responses_stream_response(&1, opts)) + end + + @spec responses_stream_events(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def responses_stream_events(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.responses_endpoint) + + do_stream(client, body, opts, &BeamWeaver.OpenAI.Streaming.lifecycle_events/1) + end + + @spec responses_stream_typed_events(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def responses_stream_typed_events(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.responses_endpoint) + + do_stream(client, body, opts, &deepseek_responses_typed_events/1) + end + + @spec completions(t() | keyword(), map(), keyword()) :: {:ok, map()} | {:error, Error.t()} + def completions(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.completions_endpoint) + + client + |> do_request(body, opts) + |> ResponseDecoder.json(opts) + end + + @spec completions_stream(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def completions_stream(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.completions_endpoint) + + do_stream(client, body, opts, &BeamWeaver.OpenAI.Streaming.text_deltas/1) + end + + @spec completions_stream_response(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def completions_stream_response(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.completions_endpoint) + + do_stream_collect(client, body, opts, &ResponseDecoder.completions_stream_response(&1, opts)) + end + + @spec models(t() | keyword(), keyword()) :: {:ok, map()} | {:error, Error.t()} + def models(client_or_opts, opts \\ []) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.models_endpoint) + + client + |> do_get(opts) + |> ResponseDecoder.json(opts) + end + + @spec balance(t() | keyword(), keyword()) :: {:ok, map()} | {:error, Error.t()} + def balance(client_or_opts, opts \\ []) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.balance_endpoint) + + client + |> do_get(opts) + |> ResponseDecoder.json(opts) + end + + @spec anthropic_messages(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def anthropic_messages(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.anthropic_messages_endpoint) + + client + |> do_request(body, opts, :anthropic) + |> ResponseDecoder.json(opts) + end + + @spec anthropic_messages_stream(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def anthropic_messages_stream(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.anthropic_messages_endpoint) + + do_stream(client, body, opts, &BeamWeaver.Anthropic.Streaming.text_deltas/1, :anthropic) + end + + @spec anthropic_messages_stream_response(t() | keyword(), map(), keyword()) :: + {:ok, map()} | {:error, Error.t()} + def anthropic_messages_stream_response(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.anthropic_messages_endpoint) + + do_stream_collect( + client, + body, + opts, + &ResponseDecoder.anthropic_stream_response(&1, opts), + :anthropic + ) + end + + @spec anthropic_messages_stream_events(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def anthropic_messages_stream_events(client_or_opts, body, opts \\ []) when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.anthropic_messages_endpoint) + + do_stream(client, body, opts, &BeamWeaver.Anthropic.Streaming.lifecycle_events/1, :anthropic) + end + + @spec anthropic_messages_stream_typed_events(t() | keyword(), map(), keyword()) :: + {:ok, Enumerable.t()} | {:error, Error.t()} + def anthropic_messages_stream_typed_events(client_or_opts, body, opts \\ []) + when is_map(body) do + client = normalize_client(client_or_opts, opts) + opts = with_endpoint(opts, client.anthropic_messages_endpoint) + + do_stream(client, body, opts, &deepseek_anthropic_typed_events/1, :anthropic) + end + + @doc false + @spec request(t(), map(), keyword()) :: Request.t() + def request(%__MODULE__{} = client, body, opts \\ []) when is_map(body) do + OpenAICompatibleClient.request(client, body, opts, &http_client/2) + end + + @doc "Returns a normalized endpoint for a base URL and path." + @spec endpoint(String.t(), String.t()) :: String.t() + def endpoint(base_url, path), do: OpenAICompatibleClient.endpoint(base_url, path) + + defp do_request(client, body, opts, auth \\ :bearer) do + OpenAICompatibleClient.post_json(client, body, opts, &http_client(&1, &2, auth)) + end + + defp do_get(client, opts) do + OpenAICompatibleClient.get(client, opts, &http_client/2) + end + + defp do_stream(client, body, opts, parser, auth \\ :bearer) do + OpenAICompatibleClient.stream_sse( + client, + body, + opts, + &http_client(&1, &2, auth), + parser, + &ResponseDecoder.stream_error(&1, opts) + ) + end + + defp do_stream_collect(client, body, opts, decoder, auth \\ :bearer) do + OpenAICompatibleClient.collect_sse( + client, + body, + opts, + &http_client(&1, &2, auth), + decoder + ) + end + + defp http_client(client, opts), do: http_client(client, opts, :bearer) + + defp http_client(client, opts, :bearer) do + OpenAICompatibleClient.http_client(:deepseek, client, opts, + auth_header: "authorization", + auth_prefix: "Bearer", + default_headers: default_headers(client, opts) + ) + end + + defp http_client(client, opts, :anthropic) do + OpenAICompatibleClient.http_client(:deepseek, client, opts, + auth_header: "x-api-key", + auth_prefix: nil, + default_headers: anthropic_headers(client, opts) + ) + end + + defp default_headers(client, opts) do + [{"user-agent", Keyword.get(opts, :user_agent, "beam_weaver-deepseek/0.1")}] + |> Kernel.++(Request.normalize_headers(client.default_headers)) + end + + defp anthropic_headers(client, opts) do + [{"anthropic-version", Keyword.get(opts, :anthropic_version, "2023-06-01")}] + |> Kernel.++(default_headers(client, opts)) + end + + defp normalize_client(client_or_opts, opts) do + OpenAICompatibleClient.normalize_client(client_or_opts, opts, &new/1, @client_fields) + end + + defp chat_completions_endpoint(client, body, opts) do + cond do + Keyword.has_key?(opts, :endpoint) -> Keyword.fetch!(opts, :endpoint) + Keyword.get(opts, :beta, false) -> client.beta_chat_completions_endpoint + beta_chat_request?(body) -> client.beta_chat_completions_endpoint + true -> client.chat_completions_endpoint + end + end + + defp beta_chat_request?(body), do: prefix_request?(body) or strict_tools?(body) + + defp prefix_request?(body) do + body + |> field("messages", []) + |> List.wrap() + |> Enum.any?(fn message -> + field(message, "role") == "assistant" and field(message, "prefix") == true + end) + end + + defp strict_tools?(body) do + body + |> field("tools", []) + |> List.wrap() + |> Enum.any?(fn tool -> + field(tool, "strict") == true or field(field(tool, "function", %{}), "strict") == true + end) + end + + defp field(map, name, default \\ nil) + + defp field(map, name, default) when is_map(map) do + Map.get(map, name, Map.get(map, String.to_atom(name), default)) + end + + defp field(_value, _name, default), do: default + + defp with_endpoint(opts, fallback) do + Keyword.put_new(opts, :endpoint, fallback) + end + + defp configured_url(opts, key, config_path, default) do + if Keyword.has_key?(opts, key) do + Keyword.fetch!(opts, key) || default + else + Config.get(config_path, default) + end + end + + defp api_root(base_url) do + base_url = base_url |> to_string() |> String.trim_trailing("/") + + case Enum.find(["/v1", "/beta", "/anthropic"], &String.ends_with?(base_url, &1)) do + nil -> base_url + suffix -> base_url |> strip_api_suffix(suffix) |> api_root() + end + end + + defp stable_base_url(base_url, api_root) do + base_url = base_url |> to_string() |> String.trim_trailing("/") + + if String.ends_with?(base_url, "/beta") or String.ends_with?(base_url, "/anthropic") do + api_root + else + base_url + end + end + + defp strip_api_suffix(base_url, suffix) do + if String.ends_with?(base_url, suffix) do + String.slice(base_url, 0, byte_size(base_url) - byte_size(suffix)) + else + base_url + end + end + + defp deepseek_chat_typed_events(events) do + OpenAICompatibleStreaming.typed_events(events, %{ + provider: :deepseek, + provider_name: "DeepSeek", + error_module: Error, + usage_metadata: &BeamWeaver.OpenAI.ChatCompletions.Messages.Response.usage_metadata/1, + stream_metadata: &empty_stream_metadata/3, + choice_usage: false, + include_chunk_id: true, + reasoning_index: 0, + unknown_delta_key: :deepseek_delta + }) + end + + defp deepseek_responses_typed_events(events) do + events + |> BeamWeaver.OpenAI.Streaming.typed_events() + |> Enum.map(&put_deepseek_provider/1) + end + + defp empty_stream_metadata(_events, _message, _opts), do: %{} + + defp deepseek_anthropic_typed_events(events) do + events + |> BeamWeaver.Anthropic.Streaming.typed_events() + |> Enum.map(&put_deepseek_provider/1) + end + + defp put_deepseek_provider(%BeamWeaver.Stream.Envelope{} = envelope) do + %{envelope | metadata: Map.put(envelope.metadata || %{}, :provider, :deepseek)} + end + + defp put_deepseek_provider(event), do: event +end + +defimpl Inspect, for: BeamWeaver.DeepSeek.Client do + def inspect(struct, opts), do: BeamWeaver.Provider.RedactedInspect.redacted_struct(struct, opts) +end diff --git a/lib/beam_weaver/deepseek/client/response_decoder.ex b/lib/beam_weaver/deepseek/client/response_decoder.ex new file mode 100644 index 0000000..c729ca9 --- /dev/null +++ b/lib/beam_weaver/deepseek/client/response_decoder.ex @@ -0,0 +1,471 @@ +defmodule BeamWeaver.DeepSeek.Client.ResponseDecoder do + @moduledoc false + + alias BeamWeaver.Core.Error, as: CoreError + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.Provider.ResponseDecoder, as: ProviderResponseDecoder + alias BeamWeaver.Transport.Response + + @doc false + def json({:ok, %Response{} = response} = result, opts) do + result + |> ProviderResponseDecoder.json(decoder_opts(opts)) + |> attach_result_headers(response, opts) + |> normalize_error() + end + + def json(result, opts) do + result + |> ProviderResponseDecoder.json(decoder_opts(opts)) + |> normalize_error() + end + + @doc false + def stream_error({:ok, %Response{} = response}, opts) do + {:error, normalize_http_error(response, opts)} + end + + def stream_error({:error, error}, opts) do + {:error, ProviderResponseDecoder.transport_error(error, decoder_opts(opts))} + end + + @doc false + def chat_completions_stream_response( + {:ok, %Response{status: status, body: body} = response}, + opts + ) + when status in 200..299 do + case chat_completion_response(body) do + {:ok, decoded} -> + {:ok, attach_headers(decoded, response, opts)} + + {:error, error} -> + {:error, + Error.new( + Map.get(error, :type, :invalid_response), + Map.get(error, :message, "DeepSeek chat-completions stream was invalid"), + Map.get(error, :details, %{}) + )} + end + end + + def chat_completions_stream_response({:ok, %Response{} = response}, opts) do + {:error, normalize_http_error(response, opts)} + end + + def chat_completions_stream_response({:error, error}, opts) do + {:error, ProviderResponseDecoder.transport_error(error, decoder_opts(opts))} + end + + @doc false + def responses_stream_response( + {:ok, %Response{status: status, body: body} = response}, + opts + ) + when status in 200..299 do + decoded = BeamWeaver.OpenAI.Streaming.response(body) + {:ok, attach_headers(decoded, response, opts)} + end + + def responses_stream_response({:ok, %Response{} = response}, opts) do + {:error, normalize_http_error(response, opts)} + end + + def responses_stream_response({:error, error}, opts) do + {:error, ProviderResponseDecoder.transport_error(error, decoder_opts(opts))} + end + + @doc false + def completions_stream_response( + {:ok, %Response{status: status, body: body} = response}, + opts + ) + when status in 200..299 do + decoded = completion_response(body) + {:ok, attach_headers(decoded, response, opts)} + end + + def completions_stream_response({:ok, %Response{} = response}, opts) do + {:error, normalize_http_error(response, opts)} + end + + def completions_stream_response({:error, error}, opts) do + {:error, ProviderResponseDecoder.transport_error(error, decoder_opts(opts))} + end + + @doc false + def anthropic_stream_response( + {:ok, %Response{status: status, body: body} = response}, + opts + ) + when status in 200..299 do + decoded = BeamWeaver.Anthropic.Streaming.response(body) + {:ok, attach_headers(decoded, response, opts)} + end + + def anthropic_stream_response({:ok, %Response{} = response}, opts) do + {:error, normalize_http_error(response, opts)} + end + + def anthropic_stream_response({:error, error}, opts) do + {:error, ProviderResponseDecoder.transport_error(error, decoder_opts(opts))} + end + + defp chat_completion_response(body) do + state = + body + |> BeamWeaver.Provider.SSE.events() + |> Enum.reduce(%{response: %{}, choices: %{}}, &apply_chat_event/2) + + if map_size(state.choices) == 0 do + {:error, Error.new(:invalid_response, "DeepSeek chat-completions stream had no choices")} + else + {:ok, finalize_chat_response(state)} + end + end + + defp apply_chat_event(%{"data" => data}, state) when is_map(data) do + response = put_response_fields(state.response, data) + + choices = + data + |> Map.get("choices", []) + |> Enum.reduce(state.choices, &apply_chat_choice/2) + + %{state | response: response, choices: choices} + end + + defp apply_chat_event(_event, state), do: state + + defp apply_chat_choice(choice, choices) when is_map(choice) do + index = choice["index"] || 0 + current = Map.get(choices, index, initial_chat_choice(index)) + delta = choice["delta"] || %{} + + current = + current + |> update_in([:message], &apply_chat_delta(&1, delta)) + |> update_in([:tool_calls], &apply_chat_tool_calls(&1, delta["tool_calls"])) + |> Map.put(:finish_reason, choice["finish_reason"] || current.finish_reason) + |> Map.put(:logprobs, merge_logprobs(current.logprobs, choice["logprobs"])) + + Map.put(choices, index, current) + end + + defp apply_chat_choice(_choice, choices), do: choices + + defp initial_chat_choice(index) do + %{index: index, message: %{}, tool_calls: %{}, finish_reason: nil, logprobs: nil} + end + + defp apply_chat_delta(message, delta) when is_map(delta) do + message + |> put_present("role", delta["role"]) + |> append_string("content", delta["content"]) + |> append_string("reasoning_content", delta["reasoning_content"]) + end + + defp apply_chat_delta(message, _delta), do: message + + defp apply_chat_tool_calls(tool_calls, calls) when is_list(calls) do + Enum.reduce(calls, tool_calls, fn call, acc -> + index = call["index"] || 0 + current = Map.get(acc, index, %{"index" => index, "function" => %{}}) + function = call["function"] || %{} + + current = + current + |> put_present("id", call["id"]) + |> put_present("type", call["type"]) + |> update_in(["function"], fn current_function -> + (current_function || %{}) + |> put_present("name", function["name"]) + |> append_string("arguments", function["arguments"]) + end) + + Map.put(acc, index, current) + end) + end + + defp apply_chat_tool_calls(tool_calls, _calls), do: tool_calls + + defp finalize_chat_response(%{response: response, choices: choices}) do + Map.put( + response, + "choices", + choices + |> Enum.sort_by(fn {index, _choice} -> index end) + |> Enum.map(fn {_index, choice} -> finalize_chat_choice(choice) end) + ) + end + + defp finalize_chat_choice(choice) do + message = Map.put_new(choice.message, "role", "assistant") + + message = + if map_size(choice.tool_calls) == 0 do + message + else + tool_calls = + choice.tool_calls + |> Enum.sort_by(fn {index, _call} -> index end) + |> Enum.map(fn {_index, call} -> Map.delete(call, "index") end) + + Map.put(message, "tool_calls", tool_calls) + end + + %{ + "index" => choice.index, + "message" => message, + "finish_reason" => choice.finish_reason, + "logprobs" => choice.logprobs + } + |> reject_nil_values() + end + + defp completion_response(body) do + body + |> BeamWeaver.Provider.SSE.events() + |> Enum.reduce(%{response: %{}, choices: %{}}, &apply_completion_event/2) + |> finalize_completion_response() + end + + defp apply_completion_event(%{"data" => data}, state) when is_map(data) do + response = put_response_fields(state.response, data) + + choices = + data + |> Map.get("choices", []) + |> Enum.reduce(state.choices, &apply_completion_choice/2) + + %{state | response: response, choices: choices} + end + + defp apply_completion_event(_event, state), do: state + + defp apply_completion_choice(choice, choices) when is_map(choice) do + index = choice["index"] || 0 + + Map.update(choices, index, normalize_completion_choice(choice), fn current -> + current + |> Map.update("text", choice["text"] || "", &(&1 <> (choice["text"] || ""))) + |> put_present("finish_reason", choice["finish_reason"]) + |> put_merged_logprobs(choice["logprobs"]) + end) + end + + defp apply_completion_choice(_choice, choices), do: choices + + defp normalize_completion_choice(choice) do + %{ + "index" => choice["index"] || 0, + "text" => choice["text"] || "", + "finish_reason" => choice["finish_reason"], + "logprobs" => choice["logprobs"] + } + |> reject_nil_values() + end + + defp finalize_completion_response(%{response: response, choices: choices}) do + Map.put( + response, + "choices", + choices + |> Enum.sort_by(fn {index, _choice} -> index end) + |> Enum.map(fn {_index, choice} -> choice end) + ) + end + + defp put_response_fields(response, data) do + response + |> put_present("id", data["id"]) + |> put_present("object", data["object"]) + |> put_present("created", data["created"]) + |> put_present("model", data["model"]) + |> put_present("system_fingerprint", data["system_fingerprint"]) + |> put_present("service_tier", data["service_tier"]) + |> put_present("usage", data["usage"]) + end + + defp put_merged_logprobs(choice, nil), do: choice + + defp put_merged_logprobs(choice, incoming) do + Map.update(choice, "logprobs", incoming, &merge_logprobs(&1, incoming)) + end + + defp merge_logprobs(current, nil), do: current + defp merge_logprobs(nil, incoming), do: incoming + + defp merge_logprobs(current, incoming) when is_map(current) and is_map(incoming) do + Map.merge(current, incoming, fn _key, left, right -> merge_logprob_value(left, right) end) + end + + defp merge_logprobs(_current, incoming), do: incoming + + defp merge_logprob_value(left, right) when is_list(left) and is_list(right), do: left ++ right + + defp merge_logprob_value(left, right) when is_map(left) and is_map(right) do + Map.merge(left, right, fn _key, nested_left, nested_right -> + merge_logprob_value(nested_left, nested_right) + end) + end + + defp merge_logprob_value(_left, right), do: right + + defp append_string(map, _key, nil), do: map + + defp append_string(map, key, value) when is_binary(value) do + Map.update(map, key, value, &(&1 <> value)) + end + + defp append_string(map, _key, _value), do: map + + defp attach_result_headers({:ok, decoded}, %Response{} = response, opts) + when is_map(decoded) do + {:ok, attach_headers(decoded, response, opts)} + end + + defp attach_result_headers(result, _response, _opts), do: result + + defp attach_headers(decoded, %Response{headers: headers}, opts) when is_map(decoded) do + decoded = attach_header_metadata(decoded, headers) + + if Keyword.get(opts, :include_response_headers, false) do + decoded + |> Map.put("_beamweaver_response_headers", Map.new(headers)) + |> Map.put( + "_beamweaver_response_header_list", + Enum.map(headers, fn {name, value} -> [name, value] end) + ) + else + decoded + end + end + + defp attach_header_metadata(decoded, headers) do + metadata = header_metadata(headers) + + if map_size(metadata) > 0 do + Map.put(decoded, "_beamweaver_response_header_metadata", metadata) + else + decoded + end + end + + defp header_metadata(headers) do + trace_id = headers |> normalized_headers() |> Map.get("x-ds-trace-id") + + %{ + headers: reject_empty_values(%{x_ds_trace_id: trace_id}), + request_id: trace_id + } + |> reject_empty_values() + end + + defp normalized_headers(headers) when is_list(headers) do + Map.new(headers, fn {name, value} -> {name |> to_string() |> String.downcase(), value} end) + rescue + _error -> %{} + end + + defp normalized_headers(_headers), do: %{} + + defp normalize_http_error(response, opts) do + response + |> ProviderResponseDecoder.http_error(decoder_opts(opts)) + |> normalize_error_value() + end + + defp normalize_error({:error, %Error{} = error}), do: {:error, normalize_error_value(error)} + defp normalize_error({:error, %CoreError{} = error}), do: {:error, normalize_error_value(error)} + defp normalize_error(other), do: other + + defp normalize_error_value(%{type: :context_overflow} = error), do: error + + defp normalize_error_value(%{type: :http_error, details: details} = error) do + provider_error = details[:error] || details["error"] || %{} + status = details[:status] || details["status"] + code = error_field(provider_error, "code") || details[:code] || details["code"] + error_type = error_field(provider_error, "type") || details[:error_type] || details["error_type"] + + type = + cond do + status == 401 -> :authentication_error + insufficient_balance?(status, code, error_type, error.message) -> :quota_error + status == 429 -> :rate_limit_error + status == 503 -> :overloaded_error + status in [400, 422] -> :invalid_request_error + status in [500, 502, 504] -> :server_error + true -> :http_error + end + + %{error | type: type, details: normalize_retryable_details(details, type)} + end + + defp normalize_error_value(error), do: error + + defp insufficient_balance?(status, code, error_type, message) do + status == 402 or + code in ["insufficient_balance", "insufficient_quota", "quota_exceeded"] or + error_type in ["insufficient_balance", "insufficient_quota", "quota_exceeded"] or + insufficient_balance_message?(message) + end + + defp insufficient_balance_message?(message) when is_binary(message) do + normalized = String.downcase(message) + + String.contains?(normalized, "insufficient balance") or + String.contains?(normalized, "insufficient quota") or + String.contains?(normalized, "quota exceeded") + end + + defp insufficient_balance_message?(_message), do: false + + defp normalize_retryable_details(details, type) + when type in [:authentication_error, :quota_error, :invalid_request_error] and is_map(details) do + Map.put(details, :retryable, false) + end + + defp normalize_retryable_details(details, _type), do: details + + defp decoder_opts(opts) do + [ + provider: :deepseek, + provider_name: "DeepSeek", + error_module: Error, + include_response_headers: Keyword.get(opts, :include_response_headers, false), + request_id_header: "x-ds-trace-id", + context_overflow?: &context_overflow?/3 + ] + end + + defp context_overflow?(400, provider_error, message) when is_binary(message) do + code = error_field(provider_error, "code") + normalized = String.downcase(message) + + code in ["context_length_exceeded", "prompt_too_long"] or + String.contains?(normalized, "context length") or + String.contains?(normalized, "context window") or + String.contains?(normalized, "maximum context") or + String.contains?(normalized, "input tokens exceed") or + String.contains?(normalized, "too many tokens") + end + + defp context_overflow?(_status, _provider_error, _message), do: false + + defp error_field(%{} = error, field), do: BeamWeaver.MapAccess.get(error, field) + defp error_field(_error, _field), do: nil + + defp put_present(map, _key, value) when value in [nil, ""], do: map + defp put_present(map, key, value), do: Map.put(map, key, value) + + defp reject_nil_values(map), do: Map.reject(map, fn {_key, value} -> is_nil(value) end) + + defp reject_empty_values(map) do + Map.reject(map, fn + {_key, value} when value in [nil, ""] -> true + {_key, value} when is_map(value) and map_size(value) == 0 -> true + _entry -> false + end) + end +end diff --git a/lib/beam_weaver/deepseek/error.ex b/lib/beam_weaver/deepseek/error.ex new file mode 100644 index 0000000..ac72043 --- /dev/null +++ b/lib/beam_weaver/deepseek/error.ex @@ -0,0 +1,20 @@ +defmodule BeamWeaver.DeepSeek.Error do + @moduledoc """ + Recoverable errors returned by the DeepSeek provider. + """ + + @enforce_keys [:type, :message] + defstruct [:type, :message, details: %{}] + + @type t :: %__MODULE__{ + type: atom(), + message: String.t(), + details: map() + } + + @doc "Builds a tagged DeepSeek provider error." + @spec new(atom(), String.t(), map()) :: t() + def new(type, message, details \\ %{}) when is_atom(type) and is_binary(message) do + %__MODULE__{type: type, message: message, details: details} + end +end diff --git a/lib/beam_weaver/deepseek/inspect.ex b/lib/beam_weaver/deepseek/inspect.ex new file mode 100644 index 0000000..433c378 --- /dev/null +++ b/lib/beam_weaver/deepseek/inspect.ex @@ -0,0 +1,3 @@ +defimpl Inspect, for: [BeamWeaver.DeepSeek.ChatModel, BeamWeaver.DeepSeek.ResponsesModel] do + def inspect(struct, opts), do: BeamWeaver.Provider.RedactedInspect.redacted_struct(struct, opts) +end diff --git a/lib/beam_weaver/deepseek/messages.ex b/lib/beam_weaver/deepseek/messages.ex new file mode 100644 index 0000000..79674b2 --- /dev/null +++ b/lib/beam_weaver/deepseek/messages.ex @@ -0,0 +1,423 @@ +defmodule BeamWeaver.DeepSeek.Messages do + @moduledoc """ + DeepSeek message translation and response normalization. + + DeepSeek uses OpenAI-compatible wire shapes, with additional handling for + reasoning replay, prefix completion, provider metadata, and cache-aware cost. + """ + + alias BeamWeaver.Core.ContentBlock + alias BeamWeaver.Core.Message + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.Models.ProfileRegistry + alias BeamWeaver.Models.UsageCost + alias BeamWeaver.OpenAI.ChatCompletions + alias BeamWeaver.OpenAI.MessageParts + alias BeamWeaver.OpenAI.Messages, as: OpenAIMessages + + @chat_allowed_content_types [:text, :plain_text, :reasoning] + @responses_allowed_content_types [ + :text, + :plain_text, + :output_text, + :reasoning, + :function_call, + :tool_call, + :tool_use, + :web_search_call, + :unknown + ] + + @spec to_chat_messages([Message.t()]) :: {:ok, [map()]} | {:error, Error.t()} + def to_chat_messages([]) do + {:error, + Error.new(:invalid_messages, "DeepSeek Chat Completions requires at least one message", %{ + provider: :deepseek, + api: :chat_completions + })} + end + + def to_chat_messages(messages) when is_list(messages) do + with :ok <- validate_text_messages(messages, :chat_completions), + {:ok, wire_messages} <- + messages + |> Enum.map(&text_only_message/1) + |> ChatCompletions.Messages.to_openai_messages() + |> convert_error(), + wire_messages <- decorate_chat_messages(wire_messages, messages), + :ok <- validate_prefix_messages(wire_messages) do + {:ok, wire_messages} + end + end + + def to_chat_messages(_messages) do + {:error, Error.new(:invalid_messages, "DeepSeek messages must be a list")} + end + + @spec validate_text_messages([Message.t()], :chat_completions | :responses) :: + :ok | {:error, Error.t()} + def validate_text_messages(messages, api) when is_list(messages) do + Enum.reduce_while(messages, :ok, fn message, :ok -> + case validate_text_message(message, api) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + @spec chat_response_to_message(map()) :: {:ok, Message.t()} | {:error, Error.t()} + def chat_response_to_message(response) when is_map(response) do + response + |> ChatCompletions.Messages.response_to_message() + |> convert_error() + |> put_provider_metadata(response, :chat_completions) + |> put_reasoning_metadata(response) + |> put_usage_cost(response) + end + + def chat_response_to_message(_response) do + {:error, Error.new(:invalid_response, "DeepSeek Chat Completions response is invalid")} + end + + @spec responses_to_message(map()) :: {:ok, Message.t()} | {:error, Error.t()} + def responses_to_message(response) when is_map(response) do + response + |> OpenAIMessages.response_to_message() + |> convert_error() + |> hydrate_responses_replay_blocks() + |> put_provider_metadata(response, :responses) + |> put_usage_cost(response) + end + + def responses_to_message(_response) do + {:error, Error.new(:invalid_response, "DeepSeek Responses response is invalid")} + end + + @doc false + @spec usage_metadata(map()) :: map() | nil + def usage_metadata(%{"usage" => usage}) when is_map(usage) do + input_tokens = usage["prompt_tokens"] || usage["input_tokens"] || 0 + output_tokens = usage["completion_tokens"] || usage["output_tokens"] || 0 + cache_hit = usage["prompt_cache_hit_tokens"] || cached_input_tokens(usage) + cache_miss = usage["prompt_cache_miss_tokens"] || max(input_tokens - cache_hit, 0) + reasoning_tokens = get_in(usage, ["completion_tokens_details", "reasoning_tokens"]) + reasoning_tokens = reasoning_tokens || get_in(usage, ["output_tokens_details", "reasoning_tokens"]) + + %{ + input_tokens: input_tokens, + output_tokens: output_tokens, + total_tokens: usage["total_tokens"] || input_tokens + output_tokens, + input_token_details: %{ + cache_read: cache_hit, + cache_miss: cache_miss + }, + output_token_details: %{ + reasoning: reasoning_tokens + } + } + |> BeamWeaver.MapShape.reject_nil_or_empty() + end + + def usage_metadata(_response), do: nil + + defp validate_text_message(%Message{content: content, role: role}, api) when is_list(content) do + Enum.reduce_while(content, :ok, fn block, :ok -> + case validate_content_block(block, role, api) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp validate_text_message(%Message{content: content}, _api) when is_binary(content), do: :ok + + defp validate_text_message(%Message{}, _api) do + {:error, Error.new(:invalid_message, "DeepSeek message content must be text")} + end + + defp validate_text_message(_message, _api) do + {:error, Error.new(:invalid_message, "expected a BeamWeaver message")} + end + + defp validate_content_block(block, role, api) do + type = content_type(block) + allowed = if api == :responses, do: @responses_allowed_content_types, else: @chat_allowed_content_types + + cond do + ContentBlock.data?(block) -> + unsupported_media(content_feature(type), api) + + type == :reasoning and role != :assistant -> + {:error, + Error.new(:invalid_message, "DeepSeek reasoning content is valid on assistant messages only", %{ + provider: :deepseek, + api: api, + role: role + })} + + type in allowed -> + :ok + + true -> + {:error, + Error.new(:unsupported_feature, "DeepSeek supports text message content only", %{ + provider: :deepseek, + api: api, + feature: content_feature(type) + })} + end + end + + defp unsupported_media(feature, api) do + {:error, + Error.new(:unsupported_feature, "DeepSeek does not support media message input", %{ + provider: :deepseek, + api: api, + feature: feature + })} + end + + defp content_type(block) when is_binary(block), do: :text + defp content_type(%ContentBlock.Text{}), do: :text + defp content_type(%ContentBlock.PlainText{}), do: :plain_text + defp content_type(%ContentBlock.Reasoning{}), do: :reasoning + defp content_type(%ContentBlock.Image{}), do: :image + defp content_type(%ContentBlock.Audio{}), do: :audio + defp content_type(%ContentBlock.File{}), do: :file + defp content_type(%ContentBlock.Video{}), do: :video + defp content_type(%ContentBlock.Unknown{}), do: :unknown + + defp content_type(block) when is_map(block) do + block + |> BeamWeaver.MapAccess.get(:type) + |> normalize_content_type() + end + + defp content_type(_block), do: :unknown + + defp normalize_content_type(type) when is_atom(type), do: type + + defp normalize_content_type(type) when is_binary(type) do + case type do + "text" -> :text + "plain_text" -> :plain_text + "output_text" -> :output_text + "reasoning" -> :reasoning + "function_call" -> :function_call + "tool_call" -> :tool_call + "tool_use" -> :tool_use + "web_search_call" -> :web_search_call + "image" -> :image + "image_url" -> :image + "input_image" -> :image + "audio" -> :audio + "input_audio" -> :audio + "file" -> :file + "input_file" -> :file + "video" -> :video + _other -> :unknown + end + end + + defp normalize_content_type(_type), do: :unknown + + defp content_feature(nil), do: :unknown_content + defp content_feature(type), do: type + + defp text_only_message(%Message{} = message), do: %{message | content: Message.text(message)} + + defp decorate_chat_messages(wire_messages, messages) do + Enum.zip(wire_messages, messages) + |> Enum.map(fn {wire, message} -> decorate_chat_message(wire, message) end) + end + + defp decorate_chat_message(wire, %Message{role: :assistant} = message) do + wire + |> put_optional("reasoning_content", assistant_reasoning(message)) + |> put_optional("prefix", prefix?(message)) + end + + defp decorate_chat_message(wire, _message), do: wire + + defp assistant_reasoning(%Message{} = message) do + metadata_value(message.metadata, :reasoning_content) || + metadata_value(message.response_metadata, :reasoning_content) || + reasoning_blocks(message.content) + end + + defp reasoning_blocks(content) when is_list(content) do + content + |> Enum.flat_map(fn + %ContentBlock.Reasoning{reasoning: reasoning} when is_binary(reasoning) -> [reasoning] + %{type: :reasoning, reasoning: reasoning} when is_binary(reasoning) -> [reasoning] + %{type: :reasoning, text: reasoning} when is_binary(reasoning) -> [reasoning] + _block -> [] + end) + |> Enum.join("") + |> case do + "" -> nil + reasoning -> reasoning + end + end + + defp reasoning_blocks(_content), do: nil + + defp prefix?(%Message{} = message) do + metadata_value(message.metadata, :prefix) || metadata_value(message.response_metadata, :prefix) + end + + defp validate_prefix_messages(messages) do + prefix_indexes = + messages + |> Enum.with_index() + |> Enum.flat_map(fn + {%{"prefix" => true}, index} -> [index] + _entry -> [] + end) + + case prefix_indexes do + [] -> + :ok + + [index] when index == length(messages) - 1 -> + :ok + + _indexes -> + {:error, + Error.new(:invalid_request, "DeepSeek prefix completion requires the final assistant message", %{ + provider: :deepseek, + feature: :chat_prefix_completion, + prefix_message_indexes: prefix_indexes + })} + end + end + + defp put_provider_metadata({:ok, %Message{} = message}, response, api) do + extras = %{ + model_provider: "deepseek", + provider: :deepseek, + api: api, + model: response["model"], + model_name: response["model"] + } + + metadata = message.metadata |> Map.merge(extras) |> MessageParts.reject_nil_values() + response_metadata = message.response_metadata |> Map.merge(extras) |> MessageParts.reject_nil_values() + usage = merge_usage_details(message.usage_metadata, response) + + {:ok, + %{ + message + | metadata: metadata, + response_metadata: response_metadata, + usage_metadata: usage + }} + end + + defp put_provider_metadata(other, _response, _api), do: other + + defp put_reasoning_metadata({:ok, %Message{} = message}, response) do + reasoning = + get_in(response, ["choices", Access.at(0), "message", "reasoning_content"]) + + metadata = put_optional(message.metadata, :reasoning_content, reasoning) + response_metadata = put_optional(message.response_metadata, :reasoning_content, reasoning) + content = prepend_reasoning_block(message.content, reasoning) + + {:ok, + %{ + message + | content: content, + metadata: metadata, + response_metadata: response_metadata + }} + end + + defp put_reasoning_metadata(other, _response), do: other + + defp hydrate_responses_replay_blocks({:ok, %Message{content: content} = message}) + when is_list(content) do + content = Enum.map(content, &hydrate_responses_replay_block/1) + {:ok, %{message | content: content}} + end + + defp hydrate_responses_replay_blocks(other), do: other + + defp hydrate_responses_replay_block(%{ + raw_provider_block: %{"type" => type} = raw_provider_block + }) + when type in ["custom_tool_call", "web_search_call"] do + ContentBlock.unknown(type, raw_provider_block) + end + + defp hydrate_responses_replay_block(block), do: block + + defp prepend_reasoning_block(content, reasoning) + when not is_binary(reasoning) or reasoning == "", + do: content + + defp prepend_reasoning_block(content, reasoning) when is_binary(content) do + [ContentBlock.reasoning(reasoning)] ++ + if(content == "", do: [], else: [ContentBlock.text(content)]) + end + + defp prepend_reasoning_block(content, reasoning) when is_list(content) do + [ContentBlock.reasoning(reasoning) | content] + end + + defp put_usage_cost({:ok, %Message{usage_metadata: usage} = message}, response) + when is_map(usage) do + with model when is_binary(model) <- response["model"], + {:ok, profile} <- ProfileRegistry.fetch(:deepseek, model), + cost when is_map(cost) <- UsageCost.calculate(profile, response["usage"] || usage) do + usage = Map.merge(usage, cost) + metadata = Map.merge(message.metadata, %{estimated_cost: cost.total_cost, cost_currency: "USD"}) + + response_metadata = + Map.merge(message.response_metadata, %{ + estimated_cost: cost.total_cost, + cost_currency: "USD" + }) + + {:ok, + %{ + message + | usage_metadata: usage, + metadata: metadata, + response_metadata: response_metadata + }} + else + _missing -> {:ok, message} + end + end + + defp put_usage_cost(other, _response), do: other + + defp merge_usage_details(nil, response), do: usage_metadata(response) + + defp merge_usage_details(usage, response) when is_map(usage) do + case usage_metadata(response) do + nil -> usage + detailed -> Map.merge(usage, detailed) + end + end + + defp cached_input_tokens(usage) do + get_in(usage, ["prompt_tokens_details", "cached_tokens"]) || + get_in(usage, ["input_tokens_details", "cached_tokens"]) || 0 + end + + defp metadata_value(metadata, key) when is_map(metadata), + do: BeamWeaver.MapAccess.get(metadata, key) + + defp metadata_value(_metadata, _key), do: nil + + defp convert_error({:error, %BeamWeaver.OpenAI.Error{} = error}) do + {:error, Error.new(error.type, error.message, error.details)} + end + + defp convert_error(other), do: other + + defp put_optional(map, _key, value) when value in [nil, false, "", []], do: map + defp put_optional(map, key, value), do: Map.put(map, key, value) +end diff --git a/lib/beam_weaver/deepseek/options.ex b/lib/beam_weaver/deepseek/options.ex new file mode 100644 index 0000000..2812cf4 --- /dev/null +++ b/lib/beam_weaver/deepseek/options.ex @@ -0,0 +1,553 @@ +defmodule BeamWeaver.DeepSeek.Options do + @moduledoc false + + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.DeepSeek.Messages + alias BeamWeaver.DeepSeek.Tools + alias BeamWeaver.Models.ParamPolicy + alias BeamWeaver.OpenAI.MessageParts + alias BeamWeaver.Provider.JsonObjectFormat + alias BeamWeaver.Provider.Options, as: ProviderOptions + + @models ["deepseek-v4-flash", "deepseek-v4-pro"] + @max_output_tokens 393_216 + @thinking_modes ["enabled", "disabled"] + @reasoning_efforts ["low", "medium", "high", "xhigh", "max"] + @deprecated_params [ + "frequency_penalty", + "presence_penalty" + ] + @unsupported_params [ + "audio", + "function_call", + "functions", + "logit_bias", + "modalities", + "n", + "parallel_tool_calls", + "prediction", + "prompt_cache_key", + "prompt_cache_options", + "prompt_cache_retention", + "safety_identifier", + "seed", + "service_tier", + "store", + "user", + "verbosity", + "web_search_options" + ] + + @spec to_body(term(), [BeamWeaver.Core.Message.t()], keyword()) :: + {:ok, map()} | {:error, Error.t()} + def to_body(model, messages, opts \\ []) do + with :ok <- validate_profile_params(model, opts), + :ok <- validate_model_kwargs(option(model, opts, :model_kwargs)), + {:ok, deepseek_messages} <- Messages.to_chat_messages(messages), + {:ok, response_format, instruction} <- response_format(model, opts), + deepseek_messages <- JsonObjectFormat.inject_instruction(deepseek_messages, instruction), + {:ok, tools} <- render_tools(Keyword.get(opts, :tools, [])), + {:ok, body} <- build_body(model, deepseek_messages, tools, response_format, opts), + :ok <- validate_body(body) do + {:ok, body} + end + end + + @doc false + @spec response_parse_opts(term(), keyword()) :: keyword() + def response_parse_opts(model, opts) when is_list(opts) do + format = effective_response_format(model, opts) + opts = Keyword.drop(opts, [:response_format, :structured_output]) + + if structured_output_format?(format) do + Keyword.put(opts, :response_format, format) + else + opts + end + end + + defp build_body(model, messages, tools, response_format, opts) do + model_name = option(model, opts, :model) + model_kwargs = option(model, opts, :model_kwargs) || %{} + + body = + %{ + "model" => model_name, + "messages" => messages + } + |> merge_model_kwargs(model_kwargs) + |> Map.put("model", model_name) + |> Map.put("messages", messages) + |> ProviderOptions.put_optional("thinking", normalize_map(option(model, opts, :thinking))) + |> ProviderOptions.put_optional( + "reasoning_effort", + normalize_value(option(model, opts, :reasoning_effort)) + ) + |> ProviderOptions.put_optional("temperature", option(model, opts, :temperature)) + |> ProviderOptions.put_optional("top_p", option(model, opts, :top_p)) + |> ProviderOptions.put_optional("max_tokens", max_tokens(model, opts)) + |> ProviderOptions.put_optional("stop", Keyword.get(opts, :stop, Map.get(model, :stop))) + |> ProviderOptions.put_optional("response_format", response_format) + |> ProviderOptions.put_optional("tools", tools) + |> ProviderOptions.put_optional( + "tool_choice", + normalize_value(Keyword.get(opts, :tool_choice, Map.get(model, :tool_choice))) + ) + |> ProviderOptions.put_optional("stream", Keyword.get(opts, :stream, false)) + |> ProviderOptions.put_optional( + "stream_options", + normalize_map(Keyword.get(opts, :stream_options)) + ) + |> maybe_put_stream_usage(model, opts) + |> ProviderOptions.put_optional("logprobs", option(model, opts, :logprobs)) + |> ProviderOptions.put_optional("top_logprobs", option(model, opts, :top_logprobs)) + |> ProviderOptions.put_optional("user_id", option(model, opts, :user_id)) + + {:ok, body} + end + + defp validate_profile_params(model, opts) do + params = + model + |> Map.from_struct() + |> Map.take([ + :logprobs, + :max_completion_tokens, + :max_output_tokens, + :max_tokens, + :model_kwargs, + :reasoning_effort, + :response_format, + :stop, + :stream_usage, + :structured_output, + :temperature, + :thinking, + :tool_choice, + :top_logprobs, + :top_p, + :user_id + ]) + |> Map.merge(Map.new(Keyword.drop(opts, [:metadata]))) + + ParamPolicy.validate( + Map.get(model, :profile), + params, + Keyword.get(opts, :param_policy, Map.get(model, :param_policy)), + api: :chat_completions, + metadata: Keyword.get(opts, :metadata, %{}) + ) + |> convert_core_error() + end + + defp response_format(model, opts) do + format = effective_response_format(model, opts) + + case format do + %{"type" => type} when type in [:text, "text"] -> + {:ok, nil, nil} + + %{type: type} when type in [:text, "text"] -> + {:ok, nil, nil} + + other -> + JsonObjectFormat.normalize(other, + error_module: Error, + error_message: "DeepSeek Chat Completions supports JSON object response_format only", + error_details: %{provider: :deepseek, api: :chat_completions} + ) + end + end + + defp render_tools([]), do: {:ok, []} + + defp render_tools(tools) when is_list(tools) do + {:ok, Tools.to_chat_tools(tools)} + rescue + exception in ArgumentError -> + {:error, Error.new(:invalid_request, exception.message, %{provider: :deepseek})} + end + + defp render_tools(_tools) do + {:error, Error.new(:invalid_request, "DeepSeek tools must be a list")} + end + + defp validate_body(body) do + with :ok <- validate_model(body["model"]), + :ok <- reject_unsupported_params(body), + :ok <- validate_tools(body["tools"]), + :ok <- validate_response_format(body["response_format"]), + :ok <- validate_thinking(body["thinking"]), + :ok <- validate_reasoning_effort(body["reasoning_effort"]), + :ok <- validate_integer_range(body["max_tokens"], :max_tokens, 1, @max_output_tokens), + :ok <- validate_number_range(body["temperature"], :temperature, 0, 2), + :ok <- validate_number_range(body["top_p"], :top_p, 0, 1), + :ok <- validate_stop(body["stop"]), + :ok <- validate_stream_options(body), + :ok <- validate_logprobs(body), + :ok <- validate_user_id(body["user_id"]), + :ok <- validate_tool_choice(body["tool_choice"], body["tools"], body["thinking"]) do + :ok + end + end + + defp validate_model_kwargs(nil), do: :ok + defp validate_model_kwargs(model_kwargs) when is_map(model_kwargs), do: :ok + + defp validate_model_kwargs(model_kwargs) do + {:error, + Error.new(:invalid_request, "DeepSeek model_kwargs must be a map", %{ + provider: :deepseek, + api: :chat_completions, + param: :model_kwargs, + value: inspect(model_kwargs) + })} + end + + defp validate_tools(nil), do: :ok + defp validate_tools(tools) when is_list(tools), do: Tools.validate_chat_tools(tools) + + defp validate_tools(tools) do + {:error, + Error.new(:invalid_request, "DeepSeek tools must be a list", %{ + provider: :deepseek, + api: :chat_completions, + param: :tools, + value: inspect(tools) + })} + end + + defp validate_response_format(nil), do: :ok + + defp validate_response_format(%{"type" => type} = format) + when type in ["text", "json_object"] and map_size(format) == 1, + do: :ok + + defp validate_response_format(response_format) do + {:error, + Error.new( + :invalid_response_format, + "DeepSeek Chat Completions supports text or JSON object response_format only", + %{ + provider: :deepseek, + api: :chat_completions, + response_format: inspect(response_format), + supported: [%{"type" => "text"}, %{"type" => "json_object"}] + } + )} + end + + defp validate_model(model) when model in @models, do: :ok + + defp validate_model(model) do + {:error, + Error.new(:unsupported_model, "DeepSeek model is not supported", %{ + provider: :deepseek, + model: model, + supported: @models, + expected: "deepseek:deepseek-v4-flash" + })} + end + + defp reject_unsupported_params(body) do + rejected = Enum.filter(@deprecated_params ++ @unsupported_params, &Map.has_key?(body, &1)) + + case rejected do + [] -> + :ok + + keys -> + {:error, + Error.new(:unsupported_model_param, "DeepSeek Chat Completions parameter is not supported", %{ + provider: :deepseek, + api: :chat_completions, + model: body["model"], + params: Enum.map(keys, &String.to_atom/1) + })} + end + end + + defp validate_thinking(nil), do: :ok + defp validate_thinking(%{"type" => type}) when type in @thinking_modes, do: :ok + + defp validate_thinking(value) do + {:error, + Error.new(:unsupported_model_param, "DeepSeek thinking must select enabled or disabled", %{ + provider: :deepseek, + param: :thinking, + value: value, + supported: Enum.map(@thinking_modes, &%{"type" => &1}) + })} + end + + defp validate_reasoning_effort(nil), do: :ok + defp validate_reasoning_effort(effort) when effort in @reasoning_efforts, do: :ok + + defp validate_reasoning_effort(effort) do + {:error, + Error.new(:unsupported_model_param, "DeepSeek reasoning_effort is not supported", %{ + provider: :deepseek, + param: :reasoning_effort, + value: effort, + supported: @reasoning_efforts + })} + end + + defp validate_stop(nil), do: :ok + defp validate_stop(stop) when is_binary(stop), do: :ok + + defp validate_stop(stop) when is_list(stop) do + cond do + length(stop) > 16 -> + stop_error(stop, "DeepSeek supports at most 16 stop sequences") + + Enum.all?(stop, &is_binary/1) -> + :ok + + true -> + stop_error(stop, "DeepSeek stop sequences must be strings") + end + end + + defp validate_stop(stop), do: stop_error(stop, "DeepSeek stop must be a string or list") + + defp stop_error(stop, message) do + {:error, + Error.new(:invalid_request, message, %{ + provider: :deepseek, + param: :stop, + value: inspect(stop) + })} + end + + defp validate_stream_options(%{"stream_options" => options}) when not is_map(options) do + {:error, + Error.new(:invalid_request, "DeepSeek stream_options must be a map", %{ + provider: :deepseek, + param: :stream_options, + value: inspect(options) + })} + end + + defp validate_stream_options(%{"stream_options" => _options, "stream" => true}), do: :ok + + defp validate_stream_options(%{"stream_options" => _options}) do + {:error, + Error.new(:unsupported_model_param, "DeepSeek stream_options requires stream: true", %{ + provider: :deepseek, + param: :stream_options, + required: %{stream: true} + })} + end + + defp validate_stream_options(_body), do: :ok + + defp validate_logprobs(%{"top_logprobs" => value, "logprobs" => true}) + when is_integer(value) and value in 0..20, + do: :ok + + defp validate_logprobs(%{"top_logprobs" => value}) do + {:error, + Error.new(:invalid_request, "DeepSeek top_logprobs requires logprobs: true and a value from 0 to 20", %{ + provider: :deepseek, + top_logprobs: value + })} + end + + defp validate_logprobs(%{"logprobs" => value}) when is_boolean(value), do: :ok + + defp validate_logprobs(%{"logprobs" => value}) do + {:error, + Error.new(:invalid_request, "DeepSeek logprobs must be a boolean", %{ + provider: :deepseek, + param: :logprobs, + value: value + })} + end + + defp validate_logprobs(_body), do: :ok + + defp validate_user_id(nil), do: :ok + + defp validate_user_id(user_id) when is_binary(user_id) do + if byte_size(user_id) <= 512 and Regex.match?(~r/^[A-Za-z0-9_-]+$/, user_id) do + :ok + else + user_id_error(user_id) + end + end + + defp validate_user_id(user_id), do: user_id_error(user_id) + + defp user_id_error(user_id) do + {:error, + Error.new(:invalid_request, "DeepSeek user_id must match [A-Za-z0-9_-]+ and be at most 512 bytes", %{ + provider: :deepseek, + param: :user_id, + value: inspect(user_id) + })} + end + + defp validate_tool_choice(nil, _tools, _thinking), do: :ok + + defp validate_tool_choice(choice, _tools, thinking) + when not is_nil(choice) and (is_nil(thinking) or thinking == %{"type" => "enabled"}) do + {:error, + Error.new( + :unsupported_model_param, + "DeepSeek thinking mode does not accept an explicit tool_choice", + %{ + provider: :deepseek, + param: :tool_choice, + value: choice, + required: %{thinking: %{type: "disabled"}} + } + )} + end + + defp validate_tool_choice(choice, _tools, _thinking) when choice in ["none", "auto"], do: :ok + + defp validate_tool_choice("required", [_tool | _rest], _thinking), do: :ok + + defp validate_tool_choice("required", _tools, _thinking) do + {:error, + Error.new(:invalid_request, "DeepSeek tool_choice required needs at least one tool", %{ + provider: :deepseek, + param: :tool_choice, + value: "required" + })} + end + + defp validate_tool_choice( + %{"type" => "function", "function" => %{"name" => name}}, + tools, + thinking + ) + when is_binary(name) do + cond do + not Enum.any?(tools || [], &(get_in(&1, ["function", "name"]) == name)) -> + {:error, + Error.new(:invalid_request, "DeepSeek named tool_choice must reference a declared tool", %{ + provider: :deepseek, + param: :tool_choice, + name: name + })} + + not thinking_disabled?(thinking) -> + {:error, + Error.new( + :unsupported_model_param, + "DeepSeek forced named tool_choice requires thinking to be explicitly disabled", + %{ + provider: :deepseek, + param: :tool_choice, + name: name, + required: %{thinking: %{type: "disabled"}} + } + )} + + true -> + :ok + end + end + + defp validate_tool_choice(value, _tools, _thinking) do + {:error, + Error.new(:unsupported_model_param, "DeepSeek tool_choice is not supported", %{ + provider: :deepseek, + param: :tool_choice, + value: value, + supported: ["none", "auto", "required", %{type: "function", function: %{name: "..."}}] + })} + end + + defp thinking_disabled?(%{"type" => "disabled"}), do: true + defp thinking_disabled?(_thinking), do: false + + defp validate_integer_range(nil, _param, _min, _max), do: :ok + + defp validate_integer_range(value, _param, min, max) + when is_integer(value) and value >= min and value <= max, + do: :ok + + defp validate_integer_range(value, param, min, max) do + range_error(param, value, "an integer from #{min} to #{max}", min, max) + end + + defp validate_number_range(nil, _param, _min, _max), do: :ok + + defp validate_number_range(value, _param, min, max) + when is_number(value) and value >= min and value <= max, + do: :ok + + defp validate_number_range(value, param, min, max) do + range_error(param, value, "a number from #{min} to #{max}", min, max) + end + + defp range_error(param, value, expected, min, max) do + {:error, + Error.new(:invalid_request, "DeepSeek #{param} must be #{expected}", %{ + provider: :deepseek, + param: param, + value: value, + min: min, + max: max + })} + end + + defp maybe_put_stream_usage(body, model, opts) do + stream? = body["stream"] == true + stream_usage? = Keyword.get(opts, :stream_usage, Map.get(model, :stream_usage, true)) + + cond do + not stream? or not stream_usage? -> + body + + is_map(body["stream_options"]) -> + update_in(body, ["stream_options"], &Map.put_new(&1, "include_usage", true)) + + Map.has_key?(body, "stream_options") -> + body + + true -> + Map.put(body, "stream_options", %{"include_usage" => true}) + end + end + + defp max_tokens(model, opts) do + option(model, opts, :max_tokens) || + option(model, opts, :max_output_tokens) || + option(model, opts, :max_completion_tokens) + end + + defp option(model, opts, key), do: Keyword.get(opts, key, Map.get(model, key)) + + defp effective_response_format(model, opts) do + option(model, opts, :response_format) || option(model, opts, :structured_output) + end + + defp structured_output_format?(nil), do: false + defp structured_output_format?(%{"type" => type}) when type in [:text, "text"], do: false + defp structured_output_format?(%{type: type}) when type in [:text, "text"], do: false + defp structured_output_format?(_format), do: true + + defp normalize_map(nil), do: nil + defp normalize_map(value) when is_map(value), do: MessageParts.stringify_keys(value) + defp normalize_map(value), do: value + + defp normalize_value(nil), do: nil + defp normalize_value(value) when is_map(value), do: MessageParts.stringify_keys(value) + defp normalize_value(value) when is_list(value), do: Enum.map(value, &normalize_value/1) + defp normalize_value(value) when is_atom(value), do: Atom.to_string(value) + defp normalize_value(value), do: value + + defp merge_model_kwargs(body, model_kwargs) when is_map(model_kwargs) do + Map.merge(body, MessageParts.stringify_keys(model_kwargs)) + end + + defp convert_core_error({:error, %BeamWeaver.Core.Error{} = error}) do + {:error, Error.new(error.type, error.message, error.details)} + end + + defp convert_core_error(other), do: other +end diff --git a/lib/beam_weaver/deepseek/provider.ex b/lib/beam_weaver/deepseek/provider.ex new file mode 100644 index 0000000..6688e97 --- /dev/null +++ b/lib/beam_weaver/deepseek/provider.ex @@ -0,0 +1,71 @@ +defmodule BeamWeaver.DeepSeek.Provider do + @moduledoc false + + @behaviour BeamWeaver.Provider.Adapter + + alias BeamWeaver.Core.Error + alias BeamWeaver.Models.ProfileRegistry + + @impl true + def provider, do: :deepseek + + @impl true + def profiles, do: ProfileRegistry.profiles(:deepseek) + + @impl true + def chat_model(opts) do + model = Keyword.get(opts, :model) + + case normalize_api(Keyword.get(opts, :api, :chat_completions)) do + {:ok, :chat_completions} -> + {:ok, BeamWeaver.DeepSeek.ChatModel} + + {:ok, :responses} when model == "deepseek-v4-pro" -> + {:error, + Error.new(:unsupported_model, "DeepSeek Responses currently supports V4 Flash only", %{ + provider: :deepseek, + api: :responses, + model: model, + supported: ["deepseek-v4-flash"], + expected: "deepseek:deepseek-v4-flash" + })} + + {:ok, :responses} -> + {:ok, BeamWeaver.DeepSeek.ResponsesModel} + + {:error, api} -> + {:error, + Error.new(:invalid_provider_option, "unsupported DeepSeek API selection", %{ + provider: :deepseek, + option: :api, + value: api, + supported: [:chat_completions, :responses] + })} + end + end + + @impl true + def profile(model), do: ProfileRegistry.fetch(:deepseek, model) + + @impl true + def infer_provider?(_model, _kind), do: false + + @impl true + def default_model(:chat), do: "deepseek-v4-flash" + def default_model(_kind), do: nil + + @impl true + def capabilities do + %{ + api_families: [:chat_completions, :responses], + openai_compatible: true, + default_api: :chat_completions + } + end + + defp normalize_api(api) when api in [:chat, :chat_completions, "chat", "chat_completions"], + do: {:ok, :chat_completions} + + defp normalize_api(api) when api in [:responses, "responses"], do: {:ok, :responses} + defp normalize_api(api), do: {:error, api} +end diff --git a/lib/beam_weaver/deepseek/responses_model.ex b/lib/beam_weaver/deepseek/responses_model.ex new file mode 100644 index 0000000..47933bb --- /dev/null +++ b/lib/beam_weaver/deepseek/responses_model.ex @@ -0,0 +1,832 @@ +defmodule BeamWeaver.DeepSeek.ResponsesModel do + @moduledoc """ + DeepSeek OpenAI-compatible Responses API model. + + DeepSeek currently exposes this API for `deepseek-v4-flash` only and keeps it + stateless. Unsupported state, media, and silently ignored compatibility + parameters are rejected before transport. + """ + + alias BeamWeaver.DeepSeek.Client + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.DeepSeek.Messages + alias BeamWeaver.DeepSeek.Tools + alias BeamWeaver.Models.InvocationMetadata + alias BeamWeaver.OpenAI.ChatModel.RequestBuilder + alias BeamWeaver.OpenAI.ChatModel.TokenCounter + alias BeamWeaver.Provider.ChatModel.Options, as: ChatOptions + alias BeamWeaver.Provider.StructuredOutput + + @default_model "deepseek-v4-flash" + @default_base_url "https://api.deepseek.com" + @default_endpoint @default_base_url <> "/responses" + @max_output_tokens 393_216 + @reasoning_efforts ~w(none low medium high xhigh max) + + @unsupported_params ~w( + audio + background + context_management + conversation + frequency_penalty + include + max_tool_calls + metadata + modalities + parallel_tool_calls + presence_penalty + previous_response_id + prompt + prompt_cache_key + prompt_cache_options + prompt_cache_retention + safety_identifier + seed + service_tier + store + stream_options + truncation + use_previous_response_id + ) + + @allowed_input_types ~w( + message + function_call + function_call_output + reasoning + web_search_call + custom_tool_call + custom_tool_call_output + ) + + # credo:disable-for-next-line Credo.Check.Warning.StructFieldAmount + defstruct model: @default_model, + base_url: @default_base_url, + endpoint: @default_endpoint, + api_key: nil, + default_headers: [], + model_kwargs: %{}, + reasoning: nil, + reasoning_effort: nil, + verbosity: nil, + temperature: nil, + max_tokens: nil, + max_completion_tokens: nil, + max_output_tokens: nil, + top_p: nil, + top_logprobs: nil, + frequency_penalty: nil, + presence_penalty: nil, + seed: nil, + parallel_tool_calls: nil, + metadata: nil, + user: nil, + service_tier: nil, + prompt_cache_key: nil, + prompt_cache_options: nil, + prompt_cache_retention: nil, + safety_identifier: nil, + modalities: nil, + audio: nil, + store: nil, + profile: nil, + tokenizer: nil, + param_policy: nil, + streaming: false, + include_response_headers: false, + transport: nil, + transport_opts: [], + timeout: 15_000 + + @type t :: %__MODULE__{} + + use BeamWeaver.Provider.ChatModel + + @spec new(keyword() | map()) :: t() + def new(opts \\ []) do + opts = ChatOptions.keyword_options(opts) + opts = normalize_endpoint_opts(opts) + model = Keyword.get(opts, :model, @default_model) + profile = ChatOptions.profile_option(opts, :deepseek, model) + + struct!( + __MODULE__, + opts + |> Keyword.put(:model, model) + |> Keyword.put(:profile, profile) + ) + end + + @spec request_body(t(), [BeamWeaver.Core.Message.t()], keyword()) :: + {:ok, map()} | {:error, Error.t()} + def request_body(%__MODULE__{} = model, messages, opts \\ []) do + with :ok <- validate_responses_model(model.model), + :ok <- validate_container_options(model, opts), + :ok <- validate_model_overrides(model, opts), + :ok <- validate_unsupported_options(model, opts), + :ok <- Messages.validate_text_messages(messages, :responses), + {:ok, body} <- model |> RequestBuilder.request_body(messages, opts) |> convert_error(), + body <- restore_reserved_fields(body, model, opts), + :ok <- validate_responses_model(body["model"]), + :ok <- validate_unsupported_body(body), + :ok <- validate_instructions(body), + :ok <- validate_required_input(body), + :ok <- validate_tools(body["tools"]), + :ok <- validate_input_items(body["input"], body["tools"]), + :ok <- validate_call_pairs(body["input"]), + :ok <- validate_request_values(body), + :ok <- validate_tool_choice(body["tool_choice"], body["tools"], body["reasoning"]) do + {:ok, body} + end + end + + def count_tokens(%__MODULE__{} = model, input, opts \\ []), + do: TokenCounter.count(model, input, opts) + + defp validate_responses_model("deepseek-v4-flash"), do: :ok + + defp validate_responses_model(model) do + {:error, + Error.new(:unsupported_model, "DeepSeek Responses currently supports V4 Flash only", %{ + provider: :deepseek, + api: :responses, + model: model, + supported: ["deepseek-v4-flash"], + expected: "deepseek:deepseek-v4-flash" + })} + end + + defp validate_container_options(model, opts) do + model_kwargs = Keyword.get(opts, :model_kwargs, model.model_kwargs) + + with :ok <- validate_optional_map(model_kwargs, :model_kwargs), + :ok <- validate_optional_map(Keyword.get(opts, :extra_body), :extra_body), + :ok <- validate_optional_map(Keyword.get(opts, :reasoning, model.reasoning), :reasoning), + :ok <- validate_optional_map(Keyword.get(opts, :text), :text), + :ok <- validate_list(Keyword.get(opts, :tools, []), :tools), + :ok <- validate_nested_optional_map(model_kwargs, "text", :text) do + :ok + end + end + + defp validate_optional_map(nil, _param), do: :ok + defp validate_optional_map(value, _param) when is_map(value), do: :ok + + defp validate_optional_map(value, param), + do: container_error(param, value, "a map") + + defp validate_list(value, _param) when is_list(value), do: :ok + defp validate_list(value, param), do: container_error(param, value, "a list") + + defp validate_nested_optional_map(nil, _key, _param), do: :ok + + defp validate_nested_optional_map(map, key, param) when is_map(map) do + map = BeamWeaver.MapShape.stringify_keys(map) + + case Map.fetch(map, key) do + :error -> :ok + {:ok, nil} -> :ok + {:ok, value} when is_map(value) -> :ok + {:ok, value} -> container_error(param, value, "a map") + end + end + + defp container_error(param, value, expected) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses #{param} must be #{expected}", %{ + provider: :deepseek, + api: :responses, + param: param, + value: inspect(value) + })} + end + + defp validate_model_overrides(model, opts) do + model_kwargs = Keyword.get(opts, :model_kwargs, model.model_kwargs) + extra_body = Keyword.get(opts, :extra_body) + + overrides = + [] + |> maybe_add_keyword_value(opts, :model) + |> maybe_add_map_value(model_kwargs, "model") + |> maybe_add_map_value(extra_body, "model") + + Enum.reduce_while(overrides, :ok, fn override, :ok -> + case validate_responses_model(override) do + :ok -> {:cont, :ok} + {:error, _error} = error -> {:halt, error} + end + end) + end + + defp maybe_add_keyword_value(values, opts, key) do + if Keyword.has_key?(opts, key), do: [Keyword.get(opts, key) | values], else: values + end + + defp maybe_add_map_value(values, nil, _key), do: values + + defp maybe_add_map_value(values, map, key) when is_map(map) do + case map |> BeamWeaver.MapShape.stringify_keys() |> Map.fetch(key) do + {:ok, value} -> [value | values] + :error -> values + end + end + + defp restore_reserved_fields(body, model, opts) do + body + |> Map.put("model", model.model) + |> Map.put("stream", Keyword.get(opts, :stream, false)) + end + + defp validate_unsupported_options(model, opts) do + model_params = model |> Map.from_struct() |> BeamWeaver.MapShape.stringify_keys() + call_params = opts |> Map.new() |> BeamWeaver.MapShape.stringify_keys() + model_kwargs = Keyword.get(opts, :model_kwargs, model.model_kwargs) + extra_body = Keyword.get(opts, :extra_body) + + with :ok <- reject_configured_unsupported_values(model_params), + :ok <- reject_unsupported_keys(call_params), + :ok <- reject_unsupported_keys(stringify_optional_map(model_kwargs)), + :ok <- reject_unsupported_keys(stringify_optional_map(extra_body)) do + :ok + end + end + + defp validate_unsupported_body(body), do: reject_unsupported_keys(body) + + defp reject_unsupported_keys(values) when is_map(values) do + values + |> unsupported_keys(&Map.has_key?(&1, &2)) + |> unsupported_keys_result() + end + + defp reject_configured_unsupported_values(values) when is_map(values) do + values + |> unsupported_keys(&(Map.get(&1, &2) != nil)) + |> unsupported_keys_result() + end + + defp unsupported_keys(values, predicate) do + Enum.filter(@unsupported_params, &predicate.(values, &1)) + end + + defp unsupported_keys_result(rejected) do + case rejected do + [] -> + :ok + + keys -> + {:error, + Error.new(:unsupported_model_param, "DeepSeek Responses parameter is not supported", %{ + provider: :deepseek, + api: :responses, + params: Enum.map(keys, &String.to_atom/1), + stateless: true + })} + end + end + + defp meaningful_key?(map, key), do: Map.get(map, key) not in [nil, false, [], %{}] + + defp stringify_optional_map(nil), do: %{} + defp stringify_optional_map(map) when is_map(map), do: BeamWeaver.MapShape.stringify_keys(map) + + defp validate_instructions(%{"instructions" => instructions}) + when is_binary(instructions) and instructions != "", + do: :ok + + defp validate_instructions(%{"instructions" => instructions}) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses instructions must be a nonempty string", %{ + provider: :deepseek, + api: :responses, + param: :instructions, + value: inspect(instructions) + })} + end + + defp validate_instructions(_body), do: :ok + + defp validate_required_input(%{"input" => [_item | _rest]}), do: :ok + + defp validate_required_input(%{"instructions" => instructions}) + when is_binary(instructions) and instructions != "", + do: :ok + + defp validate_required_input(_body) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses requires nonempty input or instructions", %{ + provider: :deepseek, + api: :responses, + required: [:input, :instructions] + })} + end + + defp validate_input_items(items, tools) when is_list(items) do + Enum.reduce_while(items, :ok, fn item, :ok -> + case validate_input_item(item, tools || []) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp validate_input_items(_items, _tools) do + {:error, Error.new(:invalid_request, "DeepSeek Responses input must be a list")} + end + + defp validate_input_item(%{"type" => "message", "content" => content}, _tools) + when is_list(content) do + Enum.reduce_while(content, :ok, fn part, :ok -> + case is_map(part) && part["type"] do + type when type in ["input_text", "output_text", "text"] -> + if is_binary(part["text"]), do: {:cont, :ok}, else: {:halt, unsupported_input(type)} + + type -> + {:halt, unsupported_input(type)} + end + end) + end + + defp validate_input_item(%{"type" => "message", "content" => content}, _tools) + when is_binary(content), + do: :ok + + defp validate_input_item(%{"type" => "message"}, _tools), + do: unsupported_input(:message_content) + + defp validate_input_item(%{"type" => "reasoning"} = item, _tools) do + if meaningful_key?(item, "summary") or meaningful_key?(item, "encrypted_content") do + {:error, + Error.new(:unsupported_feature, "DeepSeek Responses reasoning replay supports plain text only", %{ + provider: :deepseek, + api: :responses, + feature: :reasoning_replay + })} + else + :ok + end + end + + defp validate_input_item(%{"type" => "function_call"} = item, _tools) do + with :ok <- validate_call_id(item, "function_call"), + :ok <- validate_nonempty_string_field(item, "name", "function_call"), + :ok <- validate_nonempty_string_field(item, "arguments", "function_call") do + :ok + end + end + + defp validate_input_item(%{"type" => "function_call_output"} = item, _tools) do + with :ok <- validate_call_id(item, "function_call_output"), + :ok <- validate_string_field(item, "output", "function_call_output") do + :ok + end + end + + defp validate_input_item(%{"type" => "custom_tool_call", "name" => "apply_patch"} = item, tools) do + with :ok <- validate_call_id(item, "custom_tool_call"), + :ok <- validate_nonempty_string_field(item, "input", "custom_tool_call"), + true <- apply_patch_declared?(tools) do + :ok + else + false -> + {:error, + Error.new(:invalid_request, "DeepSeek custom tool replay requires the apply_patch tool", %{ + provider: :deepseek, + api: :responses, + feature: :apply_patch + })} + + {:error, _error} = error -> + error + end + end + + defp validate_input_item(%{"type" => "custom_tool_call"} = item, _tools) do + {:error, + Error.new(:unsupported_feature, "DeepSeek custom input supports apply_patch only", %{ + provider: :deepseek, + api: :responses, + feature: :custom_tool_call, + name: item["name"] + })} + end + + defp validate_input_item(%{"type" => "custom_tool_call_output"} = item, tools) do + with :ok <- validate_call_id(item, "custom_tool_call_output"), + :ok <- validate_string_field(item, "output", "custom_tool_call_output"), + true <- apply_patch_declared?(tools) do + :ok + else + false -> + {:error, + Error.new(:invalid_request, "DeepSeek custom tool replay requires the apply_patch tool", %{ + provider: :deepseek, + api: :responses, + feature: :apply_patch + })} + + {:error, _error} = error -> + error + end + end + + defp validate_input_item(%{"type" => type}, _tools) + when type in ["web_search_call"], + do: :ok + + defp validate_input_item(%{"type" => type}, _tools), do: unsupported_input(type) + defp validate_input_item(_item, _tools), do: unsupported_input(nil) + + defp validate_call_id(item, type) do + case item["call_id"] do + call_id when is_binary(call_id) and call_id != "" -> + :ok + + call_id -> + {:error, + Error.new(:invalid_request, "DeepSeek Responses call_id must be a nonempty string", %{ + provider: :deepseek, + api: :responses, + input_type: type, + call_id: call_id + })} + end + end + + defp validate_nonempty_string_field(item, field, type) do + case Map.get(item, field) do + value when is_binary(value) and value != "" -> :ok + value -> required_string_field_error(field, value, type, "a nonempty string") + end + end + + defp validate_string_field(item, field, type) do + case Map.fetch(item, field) do + {:ok, value} when is_binary(value) -> :ok + {:ok, value} -> required_string_field_error(field, value, type, "a string") + :error -> required_string_field_error(field, nil, type, "a string") + end + end + + defp required_string_field_error(field, value, type, expected) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses #{type} #{field} must be #{expected}", %{ + provider: :deepseek, + api: :responses, + input_type: type, + param: String.to_atom(field), + value: value + })} + end + + defp unsupported_input(type) do + {:error, + Error.new(:unsupported_feature, "DeepSeek Responses input item is not supported", %{ + provider: :deepseek, + api: :responses, + feature: input_feature(type), + input_type: type, + supported: @allowed_input_types + })} + end + + defp input_feature(type) when type in ["input_image", "image", "image_url"], do: :image + defp input_feature(type) when type in ["input_file", "file"], do: :file + defp input_feature(type) when type in ["input_audio", "audio"], do: :audio + defp input_feature(type), do: type + + defp validate_tools(nil), do: :ok + defp validate_tools([]), do: :ok + defp validate_tools(tools) when is_list(tools), do: Tools.validate_responses_tools(tools) + + defp validate_tools(_tools) do + {:error, Error.new(:invalid_request, "DeepSeek Responses tools must be a list")} + end + + defp validate_call_pairs(items) when is_list(items) do + with :ok <- validate_pair_type(items, "function_call", "function_call_output"), + :ok <- validate_pair_type(items, "custom_tool_call", "custom_tool_call_output") do + :ok + end + end + + defp validate_call_pairs(_items), do: :ok + + defp validate_pair_type(items, call_type, output_type) do + calls = call_ids(items, call_type) + outputs = call_ids(items, output_type) + + cond do + length(calls) != length(Enum.uniq(calls)) -> + duplicate_call_id_error(call_type, calls) + + length(outputs) != length(Enum.uniq(outputs)) -> + duplicate_call_id_error(output_type, outputs) + + Enum.sort(calls) != Enum.sort(outputs) -> + {:error, + Error.new( + :invalid_request, + "DeepSeek Responses requires exactly one output for every #{call_type}", + %{ + provider: :deepseek, + api: :responses, + call_type: call_type, + output_type: output_type, + calls: calls, + outputs: outputs + } + )} + + true -> + :ok + end + end + + defp call_ids(items, type) do + for %{"type" => ^type, "call_id" => call_id} <- items, do: call_id + end + + defp duplicate_call_id_error(type, ids) do + duplicates = ids -- Enum.uniq(ids) + + {:error, + Error.new(:invalid_request, "DeepSeek Responses call_id values must be unique per item type", %{ + provider: :deepseek, + api: :responses, + input_type: type, + call_ids: Enum.uniq(duplicates) + })} + end + + defp validate_request_values(body) do + with :ok <- + validate_integer_range( + body["max_output_tokens"], + :max_output_tokens, + 1, + @max_output_tokens + ), + :ok <- validate_number_range(body["temperature"], :temperature, 0, 2), + :ok <- validate_number_range(body["top_p"], :top_p, 0, 1), + :ok <- validate_integer_range(body["top_logprobs"], :top_logprobs, 0, 20), + :ok <- validate_boolean(body["stream"], :stream), + :ok <- validate_optional_map(body["text"], :text), + :ok <- validate_reasoning(body["reasoning"]), + :ok <- validate_user(body["user"]) do + :ok + end + end + + defp validate_boolean(value, _param) when is_boolean(value), do: :ok + + defp validate_boolean(value, param) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses #{param} must be a boolean", %{ + provider: :deepseek, + api: :responses, + param: param, + value: value + })} + end + + defp validate_reasoning(nil), do: :ok + + defp validate_reasoning(%{} = reasoning) do + case reasoning["effort"] do + nil -> + :ok + + effort when effort in @reasoning_efforts -> + :ok + + effort -> + {:error, + Error.new(:invalid_request, "DeepSeek Responses reasoning effort is not supported", %{ + provider: :deepseek, + api: :responses, + param: :reasoning, + effort: effort, + supported: @reasoning_efforts + })} + end + end + + defp validate_reasoning(value), do: container_error(:reasoning, value, "a map") + + defp validate_integer_range(nil, _param, _min, _max), do: :ok + + defp validate_integer_range(value, _param, min, max) + when is_integer(value) and value >= min and value <= max, + do: :ok + + defp validate_integer_range(value, param, min, max), + do: range_error(param, value, "an integer", min, max) + + defp validate_number_range(nil, _param, _min, _max), do: :ok + + defp validate_number_range(value, _param, min, max) + when is_number(value) and value >= min and value <= max, + do: :ok + + defp validate_number_range(value, param, min, max), + do: range_error(param, value, "a number", min, max) + + defp range_error(param, value, type, min, max) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses #{param} must be #{type} from #{min} to #{max}", %{ + provider: :deepseek, + api: :responses, + param: param, + value: value, + min: min, + max: max + })} + end + + defp validate_user(nil), do: :ok + + defp validate_user(user) when is_binary(user) do + if byte_size(user) <= 512 and Regex.match?(~r/^[A-Za-z0-9_-]+$/, user) do + :ok + else + user_error(user) + end + end + + defp validate_user(user), do: user_error(user) + + defp user_error(user) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses user must match [A-Za-z0-9_-]+ and be at most 512 bytes", %{ + provider: :deepseek, + api: :responses, + param: :user, + value: inspect(user) + })} + end + + defp validate_tool_choice(nil, _tools, _reasoning), do: :ok + defp validate_tool_choice(choice, _tools, _reasoning) when choice in ["none", "auto"], do: :ok + defp validate_tool_choice("required", [_tool | _rest], _reasoning), do: :ok + + defp validate_tool_choice("required", _tools, _reasoning) do + tool_choice_error("required", "requires at least one declared tool") + end + + defp validate_tool_choice(%{"type" => "function", "name" => name} = choice, tools, reasoning) + when is_binary(name) do + with :ok <- validate_forced_tool_reasoning(choice, reasoning), + true <- declared_function?(tools, name) do + :ok + else + false -> undeclared_tool_choice(choice, name) + {:error, _error} = error -> error + end + end + + defp validate_tool_choice(%{"type" => type} = choice, tools, _reasoning) + when type in ["web_search", "web_search_2025_08_26"] do + if declared_web_search?(tools), do: :ok, else: undeclared_tool_choice(choice, type) + end + + defp validate_tool_choice( + %{"type" => "custom", "name" => "apply_patch"} = choice, + tools, + reasoning + ) do + with :ok <- validate_forced_tool_reasoning(choice, reasoning), + true <- apply_patch_declared?(tools) do + :ok + else + false -> undeclared_tool_choice(choice, "apply_patch") + {:error, _error} = error -> error + end + end + + defp validate_tool_choice(choice, _tools, _reasoning) do + tool_choice_error(choice, "is not supported") + end + + defp validate_forced_tool_reasoning(_choice, %{"effort" => "none"}), do: :ok + + defp validate_forced_tool_reasoning(choice, reasoning) do + {:error, + Error.new( + :unsupported_model_param, + "DeepSeek Responses forced function and custom tool choices require reasoning effort none", + %{ + provider: :deepseek, + api: :responses, + param: :tool_choice, + value: choice, + reasoning: reasoning, + required: %{reasoning: %{effort: "none"}} + } + )} + end + + defp declared_function?(tools, name) do + Enum.any?(tools || [], fn + %{"type" => "function", "name" => ^name} -> true + _tool -> false + end) + end + + defp declared_web_search?(tools) do + Enum.any?(tools || [], fn + %{"type" => type} when type in ["web_search", "web_search_2025_08_26"] -> true + _tool -> false + end) + end + + defp apply_patch_declared?(tools) do + Enum.any?(tools || [], fn + %{"type" => "custom", "name" => "apply_patch"} -> true + _tool -> false + end) + end + + defp undeclared_tool_choice(choice, name) do + {:error, + Error.new(:invalid_request, "DeepSeek Responses tool_choice must reference a declared tool", %{ + provider: :deepseek, + api: :responses, + param: :tool_choice, + value: choice, + name: name + })} + end + + defp tool_choice_error(choice, reason) do + {:error, + Error.new(:unsupported_model_param, "DeepSeek Responses tool_choice #{reason}", %{ + provider: :deepseek, + api: :responses, + param: :tool_choice, + value: choice, + supported: ["none", "auto", "required", :named_function, :web_search, :apply_patch] + })} + end + + defp normalize_endpoint_opts(opts) do + base_url = Keyword.get(opts, :base_url, @default_base_url) || @default_base_url + client = Client.new(base_url: base_url) + + opts + |> Keyword.put(:base_url, base_url) + |> Keyword.put_new(:endpoint, client.responses_endpoint) + end + + defp client(%__MODULE__{} = model) do + Client.new( + base_url: model.base_url, + responses_endpoint: model.endpoint, + api_key: model.api_key, + default_headers: model.default_headers || [], + transport: model.transport, + transport_opts: model.transport_opts, + timeout: model.timeout + ) + end + + defp model_stream_metadata(%__MODULE__{} = model, body, opts) do + model + |> InvocationMetadata.provider(:deepseek, body, opts, :responses) + |> InvocationMetadata.to_metadata_map() + end + + defp convert_error({:error, %BeamWeaver.OpenAI.Error{} = error}) do + {:error, Error.new(error.type, error.message, error.details)} + end + + defp convert_error({:error, %BeamWeaver.Core.Error{} = error}) do + {:error, Error.new(error.type, error.message, error.details)} + end + + defp convert_error(other), do: other + + defp runtime_adapter do + %ChatRuntime.Adapter{ + request: &request_body/3, + invoke: fn model, body, opts -> Client.responses(client(model), body, opts) end, + stream: fn model, body, opts -> Client.responses_stream(client(model), body, opts) end, + stream_response: fn model, body, opts -> + Client.responses_stream_response(client(model), body, opts) + end, + stream_events: fn model, body, opts -> + Client.responses_stream_typed_events(client(model), body, opts) + end, + decode: fn response, _opts -> Messages.responses_to_message(response) end, + parse: fn message, opts -> + StructuredOutput.maybe_parse(message, opts, + error_module: Error, + provider_name: "DeepSeek" + ) + end, + metadata: &model_stream_metadata/3, + source: :deepseek_responses + } + end +end diff --git a/lib/beam_weaver/deepseek/tools.ex b/lib/beam_weaver/deepseek/tools.ex new file mode 100644 index 0000000..ef95e35 --- /dev/null +++ b/lib/beam_weaver/deepseek/tools.ex @@ -0,0 +1,219 @@ +defmodule BeamWeaver.DeepSeek.Tools do + @moduledoc """ + Tool declaration helpers for DeepSeek Chat Completions and Responses APIs. + """ + + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.OpenAI.ChatCompletions + alias BeamWeaver.OpenAI.ToolCalling + + @chat_function_name ~r/^[A-Za-z0-9_-]{1,64}$/ + @responses_function_name ~r/^[A-Za-z0-9_-]{1,128}$/ + @max_tools 128 + @responses_tool_types ["function", "web_search", "web_search_2025_08_26"] + + @doc "Builds an OpenAI-compatible function tool declaration." + @spec function(term(), keyword()) :: map() + def function(tool, opts \\ []), do: ToolCalling.function(tool, opts) + + @doc "Builds a DeepSeek Responses web-search declaration." + @spec web_search(keyword()) :: map() + def web_search(opts \\ []) do + type = Keyword.get(opts, :type, "web_search") + + opts + |> Keyword.delete(:type) + |> BeamWeaver.MapShape.stringify_entries() + |> Map.put("type", to_string(type)) + end + + @doc "Builds the Responses API custom apply_patch declaration supported by DeepSeek." + @spec apply_patch(keyword()) :: map() + def apply_patch(opts \\ []), do: ToolCalling.custom("apply_patch", opts) + + @doc "Converts tools to DeepSeek Chat Completions declarations." + @spec to_chat_tools([term()]) :: [map()] + def to_chat_tools(tools) when is_list(tools), do: Enum.map(tools, &to_chat_tool/1) + + @doc "Converts one tool to a DeepSeek Chat Completions declaration." + @spec to_chat_tool(term()) :: map() + def to_chat_tool(%{__struct__: _module} = tool), + do: ChatCompletions.Messages.tool_to_openai(tool) + + def to_chat_tool(tool) when is_map(tool) do + tool + |> BeamWeaver.MapShape.stringify_keys() + |> ChatCompletions.Messages.tool_to_openai() + end + + def to_chat_tool(tool), do: ChatCompletions.Messages.tool_to_openai(tool) + + @doc false + @spec validate_chat_tools([map()]) :: :ok | {:error, Error.t()} + def validate_chat_tools(tools) when is_list(tools) do + with :ok <- validate_count(tools), + :ok <- validate_each(tools, :chat_completions), + :ok <- validate_strict_set(tools) do + :ok + end + end + + @doc false + @spec validate_responses_tools([map()]) :: :ok | {:error, Error.t()} + def validate_responses_tools(tools) when is_list(tools) do + with :ok <- validate_count(tools), + :ok <- validate_each(tools, :responses), + :ok <- validate_unique_responses_names(tools) do + :ok + end + end + + defp validate_count(tools) do + if length(tools) <= @max_tools do + :ok + else + {:error, + Error.new(:invalid_request, "DeepSeek supports at most 128 tools", %{ + provider: :deepseek, + feature: :tools, + count: length(tools), + max: @max_tools + })} + end + end + + defp validate_each(tools, api) do + Enum.reduce_while(tools, :ok, fn tool, :ok -> + case validate_tool(tool, api) do + :ok -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defp validate_tool(%{"type" => "function", "function" => function}, :chat_completions) + when is_map(function), + do: validate_function(function, :chat_completions) + + defp validate_tool(%{"type" => type}, :chat_completions) do + {:error, + Error.new(:unsupported_feature, "DeepSeek Chat Completions supports function tools only", %{ + provider: :deepseek, + api: :chat_completions, + feature: :tools, + unsupported: [type], + supported: ["function"] + })} + end + + defp validate_tool(%{"type" => "function"} = function, :responses), + do: validate_function(function, :responses) + + defp validate_tool(%{"type" => type}, :responses) when type in @responses_tool_types, + do: :ok + + defp validate_tool(%{"type" => "custom", "name" => "apply_patch"}, :responses), do: :ok + + defp validate_tool(%{"type" => type} = tool, :responses) do + {:error, + Error.new(:unsupported_feature, "DeepSeek Responses tool type is not supported", %{ + provider: :deepseek, + api: :responses, + feature: :tools, + unsupported: [type], + tool: tool, + supported: @responses_tool_types ++ ["custom:apply_patch"] + })} + end + + defp validate_tool(tool, api) do + {:error, + Error.new(:invalid_request, "DeepSeek tool declaration is invalid", %{ + provider: :deepseek, + api: api, + tool: inspect(tool) + })} + end + + defp validate_function(function, api) do + name = function["name"] + parameters = function["parameters"] + name_pattern = function_name_pattern(api) + + cond do + not is_binary(name) or not Regex.match?(name_pattern, name) -> + {:error, + Error.new(:invalid_request, "DeepSeek function tool name is invalid", %{ + provider: :deepseek, + api: api, + name: name, + pattern: Regex.source(name_pattern) + })} + + not is_nil(parameters) and not is_map(parameters) -> + {:error, + Error.new(:invalid_request, "DeepSeek function parameters must be a JSON Schema object", %{ + provider: :deepseek, + api: api, + name: name + })} + + function["strict"] not in [nil, true, false] -> + {:error, + Error.new(:invalid_request, "DeepSeek function strict must be a boolean", %{ + provider: :deepseek, + api: api, + name: name, + strict: function["strict"] + })} + + true -> + :ok + end + end + + defp validate_strict_set(tools) do + functions = + for %{"type" => "function", "function" => function} when is_map(function) <- tools, + do: function + + if Enum.any?(functions, &(&1["strict"] == true)) and + Enum.any?(functions, &(&1["strict"] != true)) do + {:error, + Error.new(:invalid_request, "DeepSeek strict mode requires every function tool to be strict", %{ + provider: :deepseek, + api: :chat_completions, + feature: :strict_tools + })} + else + :ok + end + end + + defp validate_unique_responses_names(tools) do + names = + Enum.flat_map(tools, fn + %{"type" => "function", "name" => name} when is_binary(name) -> [name] + %{"type" => "custom", "name" => name} when is_binary(name) -> [name] + _tool -> [] + end) + + duplicates = names -- Enum.uniq(names) + + case Enum.uniq(duplicates) do + [] -> + :ok + + duplicate_names -> + {:error, + Error.new(:invalid_request, "DeepSeek Responses tool names must be unique", %{ + provider: :deepseek, + api: :responses, + names: duplicate_names + })} + end + end + + defp function_name_pattern(:chat_completions), do: @chat_function_name + defp function_name_pattern(:responses), do: @responses_function_name +end diff --git a/lib/beam_weaver/indexing/record_manager.ex b/lib/beam_weaver/indexing/record_manager.ex index 9b973b2..db3ab7e 100644 --- a/lib/beam_weaver/indexing/record_manager.ex +++ b/lib/beam_weaver/indexing/record_manager.ex @@ -117,7 +117,7 @@ defmodule BeamWeaver.Indexing.RecordManager do with {:ok, records} <- list(manager, list_opts) do records |> Enum.filter(&(record_before?(&1, before_time) and record_after?(&1, after_time))) - |> Enum.sort_by(×tamp_value(&1.updated_at)) + |> Enum.sort_by(&{timestamp_value(&1.updated_at), &1.id}) |> maybe_take(limit) |> Enum.map(& &1.id) end diff --git a/lib/beam_weaver/models.ex b/lib/beam_weaver/models.ex index 3d540f1..10c2807 100644 --- a/lib/beam_weaver/models.ex +++ b/lib/beam_weaver/models.ex @@ -83,7 +83,7 @@ defmodule BeamWeaver.Models do def init_chat_model(model, opts) when is_binary(model) do with {:ok, provider, model_id} <- parse_model_id(model, :chat), - {:ok, module} <- ProviderRegistry.chat_provider(provider, opts), + {:ok, module} <- ProviderRegistry.chat_provider(provider, Keyword.put(opts, :model, model_id)), {:ok, profile} <- fetch_profile(provider, model_id, opts) do {:ok, build_model(module, model_id, opts, profile)} end @@ -161,6 +161,13 @@ defmodule BeamWeaver.Models do expected: "zai:glm-5.2" })} + ["deepseek-" <> _rest = model_id] when kind == :chat -> + {:error, + Error.new(:invalid_model, "DeepSeek model identifiers require the deepseek: prefix", %{ + model: model_id, + expected: "deepseek:deepseek-v4-flash" + })} + [model_id] when model_id != "" -> {:ok, ProviderRegistry.infer_provider(model_id, kind), model_id} @@ -259,6 +266,8 @@ defmodule BeamWeaver.Models do defp provider_option_keys(module) when module in [ BeamWeaver.Google.ChatModel, + BeamWeaver.DeepSeek.ChatModel, + BeamWeaver.DeepSeek.ResponsesModel, BeamWeaver.Moonshot.ChatModel, BeamWeaver.ZAI.ChatModel, BeamWeaver.XAI.ChatModel, @@ -277,6 +286,12 @@ defmodule BeamWeaver.Models do defp configured_model(opts, BeamWeaver.Google.ChatModel), do: BeamWeaver.Google.chat_model(opts) + defp configured_model(opts, BeamWeaver.DeepSeek.ChatModel), + do: BeamWeaver.DeepSeek.chat_model(opts) + + defp configured_model(opts, BeamWeaver.DeepSeek.ResponsesModel), + do: BeamWeaver.DeepSeek.responses_model(opts) + defp configured_model(opts, BeamWeaver.Moonshot.ChatModel), do: BeamWeaver.Moonshot.chat_model(opts) defp configured_model(opts, BeamWeaver.ZAI.ChatModel), do: BeamWeaver.ZAI.chat_model(opts) diff --git a/lib/beam_weaver/models/param_policy.ex b/lib/beam_weaver/models/param_policy.ex index 769bf56..80ab131 100644 --- a/lib/beam_weaver/models/param_policy.ex +++ b/lib/beam_weaver/models/param_policy.ex @@ -102,6 +102,7 @@ defmodule BeamWeaver.Models.ParamPolicy do :truncation, :use_previous_response_id, :user, + :user_id, :user_profile_id, :verbosity, :web_search_options, diff --git a/lib/beam_weaver/models/profile_registry.ex b/lib/beam_weaver/models/profile_registry.ex index 3860a8d..2023fad 100644 --- a/lib/beam_weaver/models/profile_registry.ex +++ b/lib/beam_weaver/models/profile_registry.ex @@ -6,6 +6,7 @@ defmodule BeamWeaver.Models.ProfileRegistry do alias BeamWeaver.Core.Error alias BeamWeaver.Models.Profile alias BeamWeaver.Models.ProfileRegistry.Anthropic + alias BeamWeaver.Models.ProfileRegistry.DeepSeek alias BeamWeaver.Models.ProfileRegistry.Fake alias BeamWeaver.Models.ProfileRegistry.Fallbacks alias BeamWeaver.Models.ProfileRegistry.Google @@ -16,6 +17,7 @@ defmodule BeamWeaver.Models.ProfileRegistry do @provider_modules %{ anthropic: Anthropic, + deepseek: DeepSeek, fake: Fake, google: Google, moonshot: Moonshot, @@ -24,7 +26,7 @@ defmodule BeamWeaver.Models.ProfileRegistry do zai: ZAI } - @profile_modules [OpenAI, Anthropic, XAI, Moonshot, ZAI, Google, Fake] + @profile_modules [OpenAI, Anthropic, XAI, Moonshot, ZAI, DeepSeek, Google, Fake] @doc """ Returns all checked-in model profiles sorted by provider and id. diff --git a/lib/beam_weaver/models/profile_registry/deepseek.ex b/lib/beam_weaver/models/profile_registry/deepseek.ex new file mode 100644 index 0000000..0509bf6 --- /dev/null +++ b/lib/beam_weaver/models/profile_registry/deepseek.ex @@ -0,0 +1,152 @@ +defmodule BeamWeaver.Models.ProfileRegistry.DeepSeek do + @moduledoc false + + alias BeamWeaver.Core.Error + alias BeamWeaver.Models.Profile + alias BeamWeaver.Models.ProfileRegistry.Params + + @supported_models ["deepseek-v4-flash", "deepseek-v4-pro"] + + @retired_models ["deepseek-chat", "deepseek-reasoner"] + + @common_profile %{ + status: :active, + release_date: "2026-04-24", + last_updated: "2026-08-02", + max_input_tokens: 1_048_576, + max_output_tokens: 393_216, + text_inputs: true, + text_outputs: true, + reasoning_output: true, + tool_calling: true, + tool_call_streaming: true, + tool_choice: true, + parallel_tool_calls: true, + structured_output: true, + streaming: true, + usage_metadata: true, + chat_completions_api: true, + tokenizer: nil + } + + @flash Profile.new( + Map.merge(@common_profile, %{ + provider: :deepseek, + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + responses_api: true, + supported_params: Params.deepseek_chat_completions(), + supported_params_by_api: %{ + chat_completions: Params.deepseek_chat_completions(), + responses: Params.deepseek_responses() + }, + extra: %{ + api_families: [:chat_completions, :responses], + openai_compatible: true, + model_version: "DeepSeek-V4-Flash-0731", + thinking_modes: [:enabled, :disabled], + default_thinking_mode: :enabled, + reasoning_efforts: [:low, :high, :max], + compatibility_reasoning_efforts: %{medium: :high, xhigh: :high}, + chat_prefix_completion: :beta, + fim_completion: %{status: :beta, thinking: :disabled}, + strict_tool_calls: :beta, + automatic_context_caching: true, + concurrency_limit: 2_500, + input_price_per_mtok: 0.14, + cached_input_price_per_mtok: 0.0028, + output_price_per_mtok: 0.28, + cost_currency: "USD", + pricing_source_url: "https://api-docs.deepseek.com/quick_start/pricing/", + pricing_last_checked: "2026-08-02", + pending_peak_pricing: %{ + multiplier: 2.0, + hours: ["09:00-12:00", "14:00-18:00"], + timezone: "UTC+08:00", + effective_date: :unannounced + } + } + }) + ) + + @pro Profile.new( + Map.merge(@common_profile, %{ + provider: :deepseek, + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + responses_api: false, + supported_params: Params.deepseek_chat_completions(), + supported_params_by_api: %{ + chat_completions: Params.deepseek_chat_completions() + }, + extra: %{ + api_families: [:chat_completions], + openai_compatible: true, + model_version: "DeepSeek-V4-Pro", + thinking_modes: [:enabled, :disabled], + default_thinking_mode: :enabled, + reasoning_efforts: [:high, :max], + compatibility_reasoning_efforts: %{low: :high, medium: :high, xhigh: :max}, + chat_prefix_completion: :beta, + fim_completion: %{status: :beta, thinking: :disabled}, + strict_tool_calls: :beta, + automatic_context_caching: true, + concurrency_limit: 500, + input_price_per_mtok: 0.435, + cached_input_price_per_mtok: 0.003625, + output_price_per_mtok: 0.87, + cost_currency: "USD", + pricing_source_url: "https://api-docs.deepseek.com/quick_start/pricing/", + pricing_last_checked: "2026-08-02", + pending_peak_pricing: %{ + multiplier: 2.0, + hours: ["09:00-12:00", "14:00-18:00"], + timezone: "UTC+08:00", + effective_date: :unannounced + } + } + }) + ) + + @profiles %{ + {:deepseek, "deepseek-v4-flash"} => @flash, + {:deepseek, "deepseek-v4-pro"} => @pro + } + + def profiles_map, do: @profiles + def profiles, do: Map.values(@profiles) + + def resolve(model) when is_binary(model) do + cond do + model in @retired_models -> + retired_model_error(model) + + profile = Map.get(@profiles, {:deepseek, model}) -> + {:ok, profile} + + true -> + unsupported_model_error(model) + end + end + + defp retired_model_error(model) do + {:error, + Error.new(:deprecated_model, "DeepSeek model identifier has been retired", %{ + provider: :deepseek, + model: model, + retired_at: "2026-07-24T15:59:00Z", + supported: @supported_models, + expected: "deepseek:deepseek-v4-flash" + })} + end + + defp unsupported_model_error(model) do + {:error, + Error.new(:unsupported_model, "DeepSeek model is not supported", %{ + provider: :deepseek, + model: model, + supported: @supported_models, + expected: "deepseek:deepseek-v4-flash" + })} + end +end diff --git a/lib/beam_weaver/models/profile_registry/open_ai.ex b/lib/beam_weaver/models/profile_registry/open_ai.ex index fe29b89..3438c4f 100644 --- a/lib/beam_weaver/models/profile_registry/open_ai.ex +++ b/lib/beam_weaver/models/profile_registry/open_ai.ex @@ -7,8 +7,8 @@ defmodule BeamWeaver.Models.ProfileRegistry.OpenAI do @openai_5_6_specs [ {"gpt-5.6-sol", "GPT-5.6 Sol", 5.00, 0.50, 30.00}, - {"gpt-5.6-terra", "GPT-5.6 Terra", 2.50, 0.25, 15.00}, - {"gpt-5.6-luna", "GPT-5.6 Luna", 1.00, 0.10, 6.00} + {"gpt-5.6-terra", "GPT-5.6 Terra", 2.00, 0.20, 12.00}, + {"gpt-5.6-luna", "GPT-5.6 Luna", 0.20, 0.02, 1.20} ] @openai_frontier_specs [ @@ -39,7 +39,7 @@ defmodule BeamWeaver.Models.ProfileRegistry.OpenAI do name: name, status: :active, release_date: "2026-07-09", - last_updated: "2026-07-09", + last_updated: "2026-07-30", responses_api: true, chat_completions_api: true, tool_calling: true, @@ -68,6 +68,12 @@ defmodule BeamWeaver.Models.ProfileRegistry.OpenAI do cache_write_30m_price_per_mtok: input_price * 1.25, output_price_per_mtok: output_price, cost_currency: "USD", + pricing_source_url: "https://developers.openai.com/api/docs/pricing", + pricing_modes: [:standard, :batch, :flex, :fast, :priority], + batch_price_multiplier: 0.5, + flex_price_multiplier: 0.5, + fast_mode_price_multiplier: 2.0, + fast_mode_service_tiers: [:fast, :priority], default_reasoning_effort: :medium, reasoning_efforts: [:none, :low, :medium, :high, :xhigh, :max], reasoning_modes: [:standard, :pro], diff --git a/lib/beam_weaver/models/profile_registry/params.ex b/lib/beam_weaver/models/profile_registry/params.ex index bfecfe2..ac18f6f 100644 --- a/lib/beam_weaver/models/profile_registry/params.ex +++ b/lib/beam_weaver/models/profile_registry/params.ex @@ -214,6 +214,48 @@ defmodule BeamWeaver.Models.ProfileRegistry.Params do :user_id ] + @deepseek_chat_completions_params [ + :logprobs, + :max_completion_tokens, + :max_output_tokens, + :max_tokens, + :model_kwargs, + :reasoning_effort, + :response_format, + :stop, + :stream, + :stream_options, + :stream_usage, + :structured_output, + :temperature, + :thinking, + :tool_choice, + :tools, + :top_logprobs, + :top_p, + :user_id + ] + + @deepseek_responses_params [ + :instructions, + :max_completion_tokens, + :max_output_tokens, + :max_tokens, + :model_kwargs, + :reasoning, + :reasoning_effort, + :response_format, + :stream, + :structured_output, + :temperature, + :text, + :tool_choice, + :tools, + :top_logprobs, + :top_p, + :user + ] + @google_params [ :cached_content, :candidate_count, @@ -267,6 +309,8 @@ defmodule BeamWeaver.Models.ProfileRegistry.Params do def xai_embedding, do: @xai_embedding_params def moonshot, do: @moonshot_params def zai, do: @zai_params + def deepseek_chat_completions, do: @deepseek_chat_completions_params + def deepseek_responses, do: @deepseek_responses_params def google, do: @google_params def google_latest_flash do diff --git a/lib/beam_weaver/models/usage_cost.ex b/lib/beam_weaver/models/usage_cost.ex new file mode 100644 index 0000000..13c388d --- /dev/null +++ b/lib/beam_weaver/models/usage_cost.ex @@ -0,0 +1,114 @@ +defmodule BeamWeaver.Models.UsageCost do + @moduledoc """ + Calculates token usage costs from model profile pricing metadata. + + Pricing is read from the profile's `extra` map using the canonical + `input_price_per_mtok`, `cached_input_price_per_mtok`, and + `output_price_per_mtok` keys. Explicit cache-hit and cache-miss token counts + take precedence over deriving uncached input from the total. + """ + + alias BeamWeaver.MapAccess + alias BeamWeaver.Models.Profile + + @million 1_000_000 + + @spec calculate(Profile.t() | map(), map()) :: map() | nil + def calculate(profile, usage) when is_map(usage) do + pricing = pricing(profile) + input_price = number(pricing, :input_price_per_mtok) + output_price = number(pricing, :output_price_per_mtok) + + if is_number(input_price) and is_number(output_price) do + cached_price = number(pricing, :cached_input_price_per_mtok) || input_price + input_tokens = input_tokens(usage) + output_tokens = output_tokens(usage) + cached_tokens = cached_tokens(usage) + uncached_tokens = uncached_tokens(usage, input_tokens, cached_tokens) + + uncached_cost = mtok_cost(uncached_tokens, input_price) + cached_cost = mtok_cost(cached_tokens, cached_price) + output_cost = mtok_cost(output_tokens, output_price) + input_cost = uncached_cost + cached_cost + + %{ + input_cost: input_cost, + output_cost: output_cost, + total_cost: input_cost + output_cost, + input_cost_details: %{ + uncached: uncached_cost, + cache_read: cached_cost + }, + output_cost_details: %{ + text: output_cost + } + } + end + end + + def calculate(_profile, _usage), do: nil + + defp pricing(%Profile{extra: extra}) when is_map(extra), do: extra + + defp pricing(profile) when is_map(profile) do + case MapAccess.get(profile, :extra) do + extra when is_map(extra) -> extra + _other -> profile + end + end + + defp pricing(_profile), do: %{} + + defp input_tokens(usage), + do: token_count(usage, [:input_tokens, :prompt_tokens]) || 0 + + defp output_tokens(usage), + do: token_count(usage, [:output_tokens, :completion_tokens]) || 0 + + defp cached_tokens(usage) do + token_count(usage, [ + :prompt_cache_hit_tokens, + :cache_hit_tokens, + :cached_tokens + ]) || + nested_token_count(usage, :input_token_details, [:cache_read, :cached_tokens]) || + nested_token_count(usage, :prompt_tokens_details, [:cached_tokens]) || + nested_token_count(usage, :input_tokens_details, [:cached_tokens]) || + 0 + end + + defp uncached_tokens(usage, input_tokens, cached_tokens) do + token_count(usage, [ + :prompt_cache_miss_tokens, + :cache_miss_tokens, + :uncached_input_tokens + ]) || + nested_token_count(usage, :input_token_details, [:cache_miss, :uncached]) || + max(input_tokens - cached_tokens, 0) + end + + defp nested_token_count(usage, details_key, keys) do + case MapAccess.get(usage, details_key) do + details when is_map(details) -> token_count(details, keys) + _other -> nil + end + end + + defp token_count(map, keys) do + Enum.find_value(keys, fn key -> + case MapAccess.get(map, key) do + value when is_number(value) -> max(value, 0) + _other -> nil + end + end) + end + + defp number(map, key) do + case MapAccess.get(map, key) do + value when is_number(value) -> value + _other -> nil + end + end + + defp mtok_cost(tokens, price_per_mtok), do: tokens * price_per_mtok / @million +end diff --git a/lib/beam_weaver/moonshot/streaming.ex b/lib/beam_weaver/moonshot/streaming.ex index 99e953e..dcf9fae 100644 --- a/lib/beam_weaver/moonshot/streaming.ex +++ b/lib/beam_weaver/moonshot/streaming.ex @@ -1,247 +1,22 @@ defmodule BeamWeaver.Moonshot.Streaming do @moduledoc false - alias BeamWeaver.Core.Messages - alias BeamWeaver.Core.Messages.MessageChunk alias BeamWeaver.Moonshot.Error alias BeamWeaver.Moonshot.Messages, as: MoonshotMessages - alias BeamWeaver.OpenAI.MessageParts - alias BeamWeaver.OpenAI.Streaming.SSE - alias BeamWeaver.Stream - alias BeamWeaver.Stream.Events + alias BeamWeaver.Provider.OpenAICompatibleStreaming @spec text_deltas(binary() | [map()] | term()) :: [String.t()] - def text_deltas(body) when is_binary(body), do: body |> SSE.events() |> text_deltas() + def text_deltas(body), do: OpenAICompatibleStreaming.text_deltas(body) - def text_deltas(events) when is_list(events) do - events - |> Enum.flat_map(fn - %{"data" => %{"choices" => choices}} when is_list(choices) -> - Enum.flat_map(choices, fn choice -> - case get_in(choice, ["delta", "content"]) do - text when is_binary(text) -> [text] - _other -> [] - end - end) - - _event -> - [] - end) - end - - def text_deltas(_body), do: [] - - @spec typed_events(binary() | [map()] | term()) :: [Stream.Envelope.t()] - def typed_events(body) when is_binary(body), do: body |> SSE.events() |> typed_events() - - def typed_events(events) when is_list(events) do - chunk_events = - events - |> message_chunks() - |> Enum.flat_map(&chunk_to_events/1) - - done_events = Enum.flat_map(events, &done_event/1) - - (chunk_events ++ done_events) - |> Enum.map(&ensure_envelope/1) - end - - def typed_events(_body), do: [] + @spec typed_events(binary() | [map()] | term()) :: [BeamWeaver.Stream.Envelope.t()] + def typed_events(body), do: OpenAICompatibleStreaming.typed_events(body, config()) @spec stream_body_to_message(binary(), keyword()) :: {:ok, BeamWeaver.Core.Message.t()} | {:error, Error.t()} def stream_body_to_message(body, opts \\ []) - def stream_body_to_message(body, opts) when is_binary(body) do - events = SSE.events(body) - chunks = message_chunks(events) - - case MessageChunk.merge_many(chunks) do - nil -> - {:error, Error.new(:invalid_response, "Moonshot chat-completions stream had no chunks")} - - chunk -> - message = MessageChunk.to_message(chunk) - usage = stream_usage(events) - finish_reason = stream_finish_reason(events) - metadata = stream_metadata(events, message, opts) - - {:ok, - %{ - message - | usage_metadata: usage, - status: finish_reason, - metadata: Map.merge(message.metadata, metadata), - response_metadata: - message.response_metadata - |> Map.merge(metadata) - |> Map.merge(%{usage: usage, finish_reason: finish_reason}) - |> MessageParts.reject_nil_values() - }} - end - end - - def stream_body_to_message(_body, _opts) do - {:error, Error.new(:invalid_response, "Moonshot chat-completions stream body must be binary")} - end - - defp message_chunks(events) when is_list(events) do - events - |> Enum.reduce(%{chunks: []}, &apply_message_chunk_event/2) - |> Map.fetch!(:chunks) - |> Enum.reverse() - end - - defp apply_message_chunk_event(%{"data" => %{"choices" => choices}}, state) - when is_list(choices) do - Enum.reduce(choices, state, fn choice, acc -> - delta = choice["delta"] || %{} - - acc - |> maybe_emit_reasoning(delta) - |> maybe_emit_content(delta) - |> maybe_emit_tool_calls(delta) - |> maybe_emit_unknown_delta(delta) - end) - end - - defp apply_message_chunk_event(_event, state), do: state - - defp maybe_emit_reasoning(state, %{"reasoning_content" => reasoning}) - when is_binary(reasoning) do - emit_message_chunk( - state, - Messages.ai_chunk([%{type: :reasoning, reasoning: reasoning}], - metadata: %{reasoning_content: reasoning} - ) - ) - end - - defp maybe_emit_reasoning(state, _delta), do: state - - defp maybe_emit_content(state, %{"content" => content}) when is_binary(content), - do: emit_message_chunk(state, Messages.ai_chunk(content)) - - defp maybe_emit_content(state, _delta), do: state - - defp maybe_emit_tool_calls(state, %{"tool_calls" => calls}) when is_list(calls) do - Enum.reduce(calls, state, fn call, acc -> - function = call["function"] || %{} - - emit_message_chunk( - acc, - Messages.ai_chunk("", - tool_call_chunks: [ - Messages.tool_call_chunk( - id: call["id"], - index: call["index"], - name: function["name"], - args: function["arguments"] || "" - ) - ] - ) - ) - end) - end - - defp maybe_emit_tool_calls(state, _delta), do: state - - defp maybe_emit_unknown_delta(state, delta) when is_map(delta) do - unknown = Map.drop(delta, ["content", "reasoning_content", "tool_calls", "role"]) - - if unknown == %{} do - state - else - emit_message_chunk(state, Messages.ai_chunk("", metadata: %{moonshot_delta: unknown})) - end - end - - defp emit_message_chunk(state, chunk), do: Map.update!(state, :chunks, &[chunk | &1]) - - defp chunk_to_events(%Messages.AIChunk{} = chunk) do - token_events = - if is_binary(chunk.content) and chunk.content != "" do - [%Events.Token{text: chunk.content}] - else - [] - end - - reasoning? = reasoning_chunk?(chunk) - - tool_call_events = - Enum.map(chunk.tool_call_chunks || [], &%Events.ToolCallChunk{chunk: &1}) - - metadata = - if reasoning? do - %{provider: :moonshot, block_type: :reasoning} - else - %{provider: :moonshot} - end - - events = token_events ++ tool_call_events ++ [%Events.MessageChunk{chunk: chunk}] - Enum.map(events, &Stream.envelope(&1, metadata: metadata)) - end - - defp chunk_to_events(chunk), - do: [Stream.envelope(%Events.MessageChunk{chunk: chunk}, metadata: %{provider: :moonshot})] - - defp reasoning_chunk?(%Messages.AIChunk{content: content}) when is_list(content) do - Enum.any?(content, fn - %{"type" => "reasoning"} -> true - %{type: :reasoning} -> true - _block -> false - end) - end - - defp reasoning_chunk?(_chunk), do: false - - defp done_event(%{"data" => %{"usage" => usage} = data}) when is_map(usage) do - [%Events.Done{result: data, usage: usage}] - end - - defp done_event(%{"data" => %{"choices" => choices}}) when is_list(choices) do - case Enum.find(choices, &(get_in(&1, ["finish_reason"]) != nil)) do - nil -> [] - choice -> [%Events.Done{result: nil, usage: choice["usage"]}] - end - end - - defp done_event(_event), do: [] - - defp ensure_envelope(%Stream.Envelope{} = envelope), do: envelope - - defp ensure_envelope(event), - do: Stream.envelope(event, metadata: %{provider: :moonshot}) - - defp stream_usage(events) do - events - |> Enum.reduce(nil, fn - %{"data" => %{"usage" => usage}}, _acc when is_map(usage) -> - MoonshotMessages.usage_metadata(%{"usage" => usage}) - - %{"data" => %{"choices" => choices}}, acc when is_list(choices) -> - case Enum.find_value(choices, & &1["usage"]) do - usage when is_map(usage) -> - MoonshotMessages.usage_metadata(%{"usage" => usage}) - - _other -> - acc - end - - _event, acc -> - acc - end) - end - - defp stream_finish_reason(events) do - events - |> Enum.find_value(fn - %{"data" => %{"choices" => choices}} when is_list(choices) -> - Enum.find_value(choices, & &1["finish_reason"]) - - _event -> - nil - end) + def stream_body_to_message(body, opts) do + OpenAICompatibleStreaming.stream_body_to_message(body, config(), opts) end defp stream_metadata(events, message, opts) do @@ -319,4 +94,18 @@ defmodule BeamWeaver.Moonshot.Streaming do defp put_optional(map, _key, nil), do: map defp put_optional(map, _key, []), do: map defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp config do + %{ + provider: :moonshot, + provider_name: "Moonshot chat-completions", + error_module: Error, + usage_metadata: &MoonshotMessages.usage_metadata/1, + stream_metadata: &stream_metadata/3, + choice_usage: true, + include_chunk_id: false, + reasoning_index: nil, + unknown_delta_key: :moonshot_delta + } + end end diff --git a/lib/beam_weaver/open_ai/chat_model/request_builder.ex b/lib/beam_weaver/open_ai/chat_model/request_builder.ex index 9d5a25b..3674606 100644 --- a/lib/beam_weaver/open_ai/chat_model/request_builder.ex +++ b/lib/beam_weaver/open_ai/chat_model/request_builder.ex @@ -83,7 +83,7 @@ defmodule BeamWeaver.OpenAI.ChatModel.RequestBuilder do "stream_options", Options.normalize_option_map(Keyword.get(opts, :stream_options)) ) - |> Options.put_optional("top_logprobs", Keyword.get(opts, :top_logprobs)) + |> Options.put_optional("top_logprobs", option(model, opts, :top_logprobs)) |> Options.put_optional( "context_management", Options.normalize_option_list(Keyword.get(opts, :context_management)) diff --git a/lib/beam_weaver/open_ai/inspect.ex b/lib/beam_weaver/open_ai/inspect.ex index e6f5bca..1df535b 100644 --- a/lib/beam_weaver/open_ai/inspect.ex +++ b/lib/beam_weaver/open_ai/inspect.ex @@ -1,24 +1,7 @@ defmodule BeamWeaver.OpenAI.Inspect do @moduledoc false - import Inspect.Algebra - - alias BeamWeaver.Transport.Redactor - - def redacted_struct(%module{} = struct, opts) do - fields = - struct - |> Map.from_struct() - |> Redactor.redact() - - concat(["#", module_name(module), "<", to_doc(fields, opts), ">"]) - end - - defp module_name(module) do - module - |> Atom.to_string() - |> String.trim_leading("Elixir.") - end + defdelegate redacted_struct(struct, opts), to: BeamWeaver.Provider.RedactedInspect end defimpl Inspect, @@ -30,5 +13,5 @@ defimpl Inspect, BeamWeaver.OpenAI.ModerationMiddleware, BeamWeaver.OpenAI.ResponsesModel ] do - def inspect(struct, opts), do: BeamWeaver.OpenAI.Inspect.redacted_struct(struct, opts) + def inspect(struct, opts), do: BeamWeaver.Provider.RedactedInspect.redacted_struct(struct, opts) end diff --git a/lib/beam_weaver/open_ai/messages/request.ex b/lib/beam_weaver/open_ai/messages/request.ex index a7a44b8..3b1cc31 100644 --- a/lib/beam_weaver/open_ai/messages/request.ex +++ b/lib/beam_weaver/open_ai/messages/request.ex @@ -373,6 +373,19 @@ defmodule BeamWeaver.OpenAI.Messages.Request do |> sanitize_store_replay_item(opts) end + defp assistant_content_event(%ContentBlock.Reasoning{} = block, _message_id, opts) do + block + |> sanitize_output_item(opts) + |> sanitize_store_replay_item(opts) + end + + defp assistant_content_event(%{"type" => type} = block, _message_id, opts) + when type in [:reasoning, "reasoning"] do + block + |> sanitize_output_item(opts) + |> sanitize_store_replay_item(opts) + end + defp assistant_content_event(%{type: type} = block, _message_id, opts) when type in [:function_call, :tool_call, :tool_use] do BeamWeaver.MapShape.assert_atom_keys!(block) @@ -486,11 +499,14 @@ defmodule BeamWeaver.OpenAI.Messages.Request do defp provider_type(type) when is_atom(type), do: Atom.to_string(type) defp provider_type(type), do: type - defp sanitize_output_item(%{type: :reasoning} = block, _opts) do - block - |> Shared.stringify_keys() - |> Map.take(["type", "id", "summary", "status", "encrypted_content"]) - |> Shared.reject_nil_values() + defp sanitize_output_item(%{type: type} = block, _opts) + when type in [:reasoning, "reasoning"] do + sanitize_reasoning_output_item(block) + end + + defp sanitize_output_item(%{"type" => type} = block, _opts) + when type in [:reasoning, "reasoning"] do + sanitize_reasoning_output_item(block) end defp sanitize_output_item(block, _opts) when is_map(block) do @@ -499,11 +515,23 @@ defmodule BeamWeaver.OpenAI.Messages.Request do |> drop_internal_provider_fields() end + defp sanitize_reasoning_output_item(block) do + block + |> Shared.stringify_keys() + |> Map.take(["type", "id", "summary", "status", "encrypted_content"]) + |> Shared.reject_nil_values() + |> Shared.put_optional("content", reasoning_replay_content(block)) + end + defp sanitize_store_replay_item(%{"type" => "reasoning", "encrypted_content" => encrypted} = item, opts) when is_binary(encrypted) and encrypted != "" do {:item, maybe_drop_store_false_id(item, opts)} end + defp sanitize_store_replay_item(%{"type" => "reasoning", "content" => [_part | _rest]} = item, opts) do + {:item, maybe_drop_store_false_id(item, opts)} + end + defp sanitize_store_replay_item(%{"type" => "reasoning"} = item, opts) do if store_false?(opts), do: :skip, else: {:item, maybe_drop_store_false_id(item, opts)} end @@ -523,6 +551,34 @@ defmodule BeamWeaver.OpenAI.Messages.Request do defp sanitize_store_replay_item(item, _opts), do: {:item, item} + defp reasoning_replay_content(block) do + raw_provider_block = BeamWeaver.MapAccess.get(block, :raw_provider_block, %{}) + content = BeamWeaver.MapAccess.get(block, :content) + raw_content = BeamWeaver.MapAccess.get(raw_provider_block, :content) + summary = BeamWeaver.MapAccess.get(block, :summary) + encrypted_content = BeamWeaver.MapAccess.get(block, :encrypted_content) + + cond do + match?([_part | _rest], content) -> Shared.stringify_value(content) + match?([_part | _rest], raw_content) -> Shared.stringify_value(raw_content) + replayable_plain_reasoning?(summary, encrypted_content) -> plain_reasoning_content(block) + true -> nil + end + end + + defp replayable_plain_reasoning?(summary, encrypted_content), + do: summary in [nil, []] and encrypted_content in [nil, ""] + + defp plain_reasoning_content(block) do + case BeamWeaver.MapAccess.first(block, [:reasoning, :text]) do + text when is_binary(text) and text != "" -> + [%{"type" => "reasoning_text", "text" => text}] + + _missing -> + nil + end + end + defp maybe_drop_store_false_id(item, opts) when is_map(item) do if store_false?(opts), do: Map.drop(item, ["id"]), else: item end diff --git a/lib/beam_weaver/open_ai/messages/response.ex b/lib/beam_weaver/open_ai/messages/response.ex index 8f9f5d0..4869a18 100644 --- a/lib/beam_weaver/open_ai/messages/response.ex +++ b/lib/beam_weaver/open_ai/messages/response.ex @@ -221,7 +221,27 @@ defmodule BeamWeaver.OpenAI.Messages.Response do defp reasoning_text(%{"reasoning" => reasoning}) when is_binary(reasoning), do: reasoning - defp reasoning_text(%{"summary" => summaries}) when is_list(summaries) do + defp reasoning_text(%{"content" => content} = item) when is_list(content) do + text = + content + |> Enum.flat_map(fn + %{"type" => type, "text" => text} + when type in ["reasoning_text", "text"] and is_binary(text) -> + [text] + + _part -> + [] + end) + |> Enum.join("") + + if text == "", do: reasoning_summary_text(item["summary"]), else: text + end + + defp reasoning_text(%{"summary" => summaries}), do: reasoning_summary_text(summaries) + + defp reasoning_text(_item), do: nil + + defp reasoning_summary_text(summaries) when is_list(summaries) do text = summaries |> Enum.flat_map(fn @@ -235,7 +255,7 @@ defmodule BeamWeaver.OpenAI.Messages.Response do if text == "", do: nil, else: text end - defp reasoning_text(_item), do: nil + defp reasoning_summary_text(_summaries), do: nil defp put_part_item_id(parts, nil), do: parts diff --git a/lib/beam_weaver/open_ai/streaming/lifecycle.ex b/lib/beam_weaver/open_ai/streaming/lifecycle.ex index 8030860..fe749c8 100644 --- a/lib/beam_weaver/open_ai/streaming/lifecycle.ex +++ b/lib/beam_weaver/open_ai/streaming/lifecycle.ex @@ -22,6 +22,34 @@ defmodule BeamWeaver.OpenAI.Streaming.Lifecycle do "compaction" ] + @output_item_type_set Map.new(@output_item_types, &{&1, true}) + + @doc false + @spec typed_custom_event?(map()) :: boolean() + def typed_custom_event?(%{ + "type" => type, + "item" => %{"type" => item_type} + }) + when type in ["response.output_item.added", "response.output_item.done"], + do: item_type in @output_item_types + + def typed_custom_event?(%{"type" => "response.custom_tool_call_input." <> _suffix}), + do: true + + def typed_custom_event?(%{"type" => "response." <> event_name}) do + case :binary.match(event_name, ".") do + {separator, 1} -> + event_name + |> binary_part(0, separator) + |> then(&Map.has_key?(@output_item_type_set, &1)) + + :nomatch -> + false + end + end + + def typed_custom_event?(_event), do: false + @spec events(binary() | [map()] | term()) :: [map()] def events(body) when is_binary(body) do body diff --git a/lib/beam_weaver/open_ai/streaming/messages.ex b/lib/beam_weaver/open_ai/streaming/messages.ex index fb655c1..8cbcf34 100644 --- a/lib/beam_weaver/open_ai/streaming/messages.ex +++ b/lib/beam_weaver/open_ai/streaming/messages.ex @@ -2,7 +2,9 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do @moduledoc false alias BeamWeaver.Core.ContentBlock + alias BeamWeaver.Core.Error, as: CoreError alias BeamWeaver.Core.Messages + alias BeamWeaver.OpenAI.Streaming.Lifecycle alias BeamWeaver.Stream alias BeamWeaver.Stream.Events @@ -15,12 +17,8 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do def message_chunks(parsed_events) when is_list(parsed_events) do parsed_events - |> Enum.reduce( - %{chunks: [], item_call_ids: %{}, item_names: %{}}, - &apply_message_chunk_event/2 - ) - |> Map.fetch!(:chunks) - |> Enum.reverse() + |> positioned_message_chunks() + |> Enum.map(fn {_position, chunk} -> chunk end) end def message_chunks(_body), do: [] @@ -33,21 +31,51 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do end def typed_events(parsed_events) when is_list(parsed_events) do - chunk_events = + message_events = parsed_events - |> message_chunks() - |> Enum.flat_map(&chunk_to_events/1) + |> positioned_message_chunks() + |> Enum.flat_map(fn {position, chunk} -> + chunk + |> chunk_to_events() + |> Enum.map(&{position, &1}) + end) - done_events = + {event_groups, _remaining_message_events} = parsed_events - |> Enum.flat_map(&done_event/1) + |> Enum.with_index() + |> Enum.map_reduce(message_events, fn {event, position}, pending_message_events -> + {current_message_events, remaining_message_events} = + Enum.split_while(pending_message_events, fn {event_position, _event} -> + event_position == position + end) + + events = + Enum.map(current_message_events, fn {_position, event} -> event end) ++ + custom_events(event) ++ done_event(event) + + {events, remaining_message_events} + end) - (chunk_events ++ done_events) - |> Enum.map(&Stream.envelope(&1, metadata: %{provider: :openai})) + event_groups + |> List.flatten() + |> Enum.map(fn event -> + Stream.envelope(event, metadata: %{provider: :openai}) + end) end def typed_events(_body), do: [] + defp positioned_message_chunks(parsed_events) do + parsed_events + |> Enum.with_index() + |> Enum.reduce(%{chunks: [], event_index: nil, item_call_ids: %{}, item_names: %{}}, fn + {event, event_index}, state -> + apply_message_chunk_event(event, %{state | event_index: event_index}) + end) + |> Map.fetch!(:chunks) + |> Enum.reverse() + end + defp apply_message_chunk_event( %{ "data" => @@ -187,7 +215,9 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do defp apply_message_chunk_event(_event, state), do: state - defp emit_message_chunk(state, chunk), do: Map.update!(state, :chunks, &[chunk | &1]) + defp emit_message_chunk(state, chunk) do + Map.update!(state, :chunks, &[{state.event_index, chunk} | &1]) + end defp maybe_emit_unknown_chat_delta(state, delta) when is_map(delta) do unknown = Map.drop(delta, ["content", "tool_calls", "role"]) @@ -213,10 +243,64 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do defp chunk_to_events(chunk), do: [%Events.MessageChunk{chunk: chunk}] + defp custom_events(%{"data" => data} = event) when is_map(data) do + if Lifecycle.typed_custom_event?(data) do + [%Events.Custom{payload: event, metadata: custom_event_metadata(data)}] + else + [] + end + end + + defp custom_events(_event), do: [] + + defp custom_event_metadata(data) do + item = if is_map(data["item"]), do: data["item"], else: %{} + + %{ + event_type: data["type"], + item_id: data["item_id"] || item["id"], + output_index: data["output_index"], + output_item_type: item["type"], + sequence_number: data["sequence_number"], + status: data["status"] || item["status"] + } + |> reject_nil_values() + end + defp done_event(%{"data" => %{"type" => "response.completed", "response" => response}}) do [%Events.Done{result: response, usage: response["usage"]}] end + defp done_event(%{ + "data" => %{"type" => "response.incomplete", "response" => response} = data + }) + when is_map(response) do + [ + %Events.Done{ + result: response, + usage: response["usage"], + metadata: terminal_metadata(data, response, "incomplete") + } + ] + end + + defp done_event(%{"data" => %{"type" => "response.failed", "response" => response} = data}) + when is_map(response) do + metadata = terminal_metadata(data, response, "failed") + provider_error = if is_map(response["error"]), do: response["error"], else: %{} + message = provider_error["message"] || "Model response failed" + + error = + CoreError.new(:response_failed, message, %{ + event: data, + response: response, + status: metadata.status, + usage: response["usage"] + }) + + [%Events.Error{error: error, metadata: metadata}] + end + defp done_event(%{"data" => %{"usage" => usage} = data}) when is_map(usage) do [%Events.Done{result: data, usage: usage}] end @@ -228,4 +312,16 @@ defmodule BeamWeaver.OpenAI.Streaming.Messages do end defp done_event(_event), do: [] + + defp terminal_metadata(data, response, default_status) do + %{ + event_type: data["type"], + sequence_number: data["sequence_number"], + status: response["status"] || default_status, + usage: response["usage"] + } + |> reject_nil_values() + end + + defp reject_nil_values(map), do: Map.reject(map, fn {_key, value} -> is_nil(value) end) end diff --git a/lib/beam_weaver/provider/chat_runtime.ex b/lib/beam_weaver/provider/chat_runtime.ex index aa77d1d..f642005 100644 --- a/lib/beam_weaver/provider/chat_runtime.ex +++ b/lib/beam_weaver/provider/chat_runtime.ex @@ -27,7 +27,7 @@ defmodule BeamWeaver.Provider.ChatRuntime do request_body(model, messages, Keyword.put(call_opts, :stream, false), adapter), {:ok, response} <- adapter.invoke.(model, body, call_opts), {:ok, message} <- adapter.decode.(response, call_opts) do - parse_response(message, call_opts, adapter) + parse_response(model, message, call_opts, adapter) end end @@ -44,7 +44,7 @@ defmodule BeamWeaver.Provider.ChatRuntime do request_body(model, messages, Keyword.put(call_opts, :stream, true), adapter), {:ok, response} <- adapter.stream_response.(model, body, call_opts), {:ok, message} <- adapter.decode.(response, call_opts) do - parse_response(message, call_opts, adapter) + parse_response(model, message, call_opts, adapter) end end @@ -101,10 +101,11 @@ defmodule BeamWeaver.Provider.ChatRuntime do defp request_body(model, messages, opts, %Adapter{} = adapter), do: adapter.request.(model, messages, opts) - defp parse_response(message, opts, %Adapter{} = adapter) do + defp parse_response(model, message, opts, %Adapter{} = adapter) do case adapter.parse do nil -> {:ok, message} - parser -> parser.(message, opts) + parser when is_function(parser, 3) -> parser.(model, message, opts) + parser when is_function(parser, 2) -> parser.(message, opts) end end diff --git a/lib/beam_weaver/provider/chat_runtime/adapter.ex b/lib/beam_weaver/provider/chat_runtime/adapter.ex index e053a22..e320f3f 100644 --- a/lib/beam_weaver/provider/chat_runtime/adapter.ex +++ b/lib/beam_weaver/provider/chat_runtime/adapter.ex @@ -13,7 +13,9 @@ defmodule BeamWeaver.Provider.ChatRuntime.Adapter do @type request_fun :: (term(), [Message.t()], keyword() -> {:ok, map()} | {:error, Error.t() | term()}) @type provider_fun :: (term(), map(), keyword() -> {:ok, term()} | {:error, Error.t() | term()}) @type decode_fun :: (term(), keyword() -> {:ok, Message.t()} | {:error, Error.t() | term()}) - @type parse_fun :: (Message.t(), keyword() -> {:ok, Message.t()} | {:error, Error.t() | term()}) + @type parse_fun :: + (Message.t(), keyword() -> {:ok, Message.t()} | {:error, Error.t() | term()}) + | (term(), Message.t(), keyword() -> {:ok, Message.t()} | {:error, Error.t() | term()}) @type metadata_fun :: (term(), map(), keyword() -> map()) @enforce_keys [:request, :invoke, :stream, :stream_response, :decode] diff --git a/lib/beam_weaver/provider/json_object_format.ex b/lib/beam_weaver/provider/json_object_format.ex new file mode 100644 index 0000000..f4b3f31 --- /dev/null +++ b/lib/beam_weaver/provider/json_object_format.ex @@ -0,0 +1,92 @@ +defmodule BeamWeaver.Provider.JsonObjectFormat do + @moduledoc false + + alias BeamWeaver.Core.Error + alias BeamWeaver.JSON + alias BeamWeaver.MapAccess + + @wire_format %{"type" => "json_object"} + + @spec normalize(term(), keyword()) :: + {:ok, map() | nil, map() | nil} | {:error, struct()} + def normalize(format, opts \\ []) + + def normalize(nil, _opts), do: {:ok, nil, nil} + + def normalize(%{"type" => type}, _opts) when type in [:json_object, "json_object"], + do: {:ok, @wire_format, nil} + + def normalize(%{type: type}, _opts) when type in [:json_object, "json_object"], + do: {:ok, @wire_format, nil} + + def normalize(%{name: name, schema: schema} = format, _opts) + when is_binary(name) and is_map(schema) do + {:ok, @wire_format, instruction(name, schema, Map.get(format, :strict))} + end + + def normalize(%{"name" => name, "schema" => schema} = format, _opts) + when is_binary(name) and is_map(schema) do + {:ok, @wire_format, instruction(name, schema, Map.get(format, "strict"))} + end + + def normalize({name, schema}, _opts) when is_binary(name) and is_map(schema) do + {:ok, @wire_format, instruction(name, schema)} + end + + def normalize(other, opts) do + error_module = Keyword.get(opts, :error_module, Error) + message = Keyword.get(opts, :error_message, "provider supports JSON object response_format only") + + details = + Keyword.get(opts, :error_details, %{}) + |> Map.merge(%{ + response_format: inspect(other), + supported: [@wire_format] + }) + + {:error, apply(error_module, :new, [:invalid_response_format, message, details])} + end + + @spec instruction(String.t(), map(), boolean() | nil) :: map() + def instruction(name, schema, strict \\ nil) when is_binary(name) and is_map(schema) do + %{ + "role" => "system", + "content" => instruction_text(name, schema, strict) + } + end + + @spec inject_instruction([map()], map() | nil) :: [map()] + def inject_instruction(messages, nil) when is_list(messages), do: messages + + def inject_instruction(messages, instruction) + when is_list(messages) and is_map(instruction) do + {system_messages, rest} = + Enum.split_while(messages, fn message -> + Map.get(message, "role") == "system" + end) + + system_messages ++ [instruction] ++ rest + end + + defp instruction_text(name, schema, strict) do + schema = BeamWeaver.MapShape.normalize_value(schema) + required = schema |> MapAccess.get(:required, []) |> List.wrap() + required_text = if required == [], do: "none", else: Enum.map_join(required, ", ", &to_string/1) + + strict_text = + if strict == false, do: "Validate against this schema.", else: "Strictly validate against this schema." + + """ + BeamWeaver structured output contract: + - Return exactly one JSON object and no markdown, prose, code fences, or commentary. + - #{strict_text} + - Include every required key. Required keys: #{required_text}. + - If information is unavailable, still include the key with a value compatible with the schema. + + Schema name: #{name} + JSON Schema: + #{JSON.encode!(schema, pretty: true)} + """ + |> String.trim() + end +end diff --git a/lib/beam_weaver/provider/open_ai_compatible_streaming.ex b/lib/beam_weaver/provider/open_ai_compatible_streaming.ex new file mode 100644 index 0000000..bf04a41 --- /dev/null +++ b/lib/beam_weaver/provider/open_ai_compatible_streaming.ex @@ -0,0 +1,284 @@ +defmodule BeamWeaver.Provider.OpenAICompatibleStreaming do + @moduledoc false + + alias BeamWeaver.Core.Messages + alias BeamWeaver.Core.Messages.MessageChunk + alias BeamWeaver.OpenAI.MessageParts + alias BeamWeaver.OpenAI.Streaming.SSE + alias BeamWeaver.Stream + alias BeamWeaver.Stream.Events + + @type config :: %{ + required(:provider) => atom(), + required(:provider_name) => String.t(), + required(:error_module) => module(), + required(:usage_metadata) => (map() -> map() | nil), + required(:stream_metadata) => ([map()], BeamWeaver.Core.Message.t(), keyword() -> map()), + optional(:choice_usage) => boolean(), + optional(:include_chunk_id) => boolean(), + optional(:reasoning_index) => non_neg_integer() | nil, + optional(:unknown_delta_key) => atom() + } + + @spec text_deltas(binary() | [map()] | term()) :: [String.t()] + def text_deltas(body) when is_binary(body), do: body |> SSE.events() |> text_deltas() + + def text_deltas(events) when is_list(events) do + Enum.flat_map(events, fn + %{"data" => %{"choices" => choices}} when is_list(choices) -> + Enum.flat_map(choices, fn choice -> + case get_in(choice, ["delta", "content"]) do + text when is_binary(text) -> [text] + _other -> [] + end + end) + + _event -> + [] + end) + end + + def text_deltas(_body), do: [] + + @spec typed_events(binary() | [map()] | term(), config()) :: [Stream.Envelope.t()] + def typed_events(body, config) when is_binary(body), + do: body |> SSE.events() |> typed_events(config) + + def typed_events(events, config) when is_list(events) do + chunk_events = + events + |> message_chunks(config) + |> Enum.flat_map(&chunk_to_events(&1, config)) + + done_events = Enum.flat_map(events, &done_event(&1, config)) + + Enum.map(chunk_events ++ done_events, &ensure_envelope(&1, config)) + end + + def typed_events(_body, _config), do: [] + + @spec stream_body_to_message(binary() | term(), config(), keyword()) :: + {:ok, BeamWeaver.Core.Message.t()} | {:error, term()} + def stream_body_to_message(body, config, opts \\ []) + + def stream_body_to_message(body, config, opts) when is_binary(body) do + events = SSE.events(body) + chunks = message_chunks(events, config) + + case MessageChunk.merge_many(chunks) do + nil -> + {:error, error(config, :invalid_response, "#{config.provider_name} stream had no chunks")} + + chunk -> + message = MessageChunk.to_message(chunk) + usage = stream_usage(events, config) + finish_reason = stream_finish_reason(events) + metadata = config.stream_metadata.(events, message, opts) + message = maybe_put_message_id(message, metadata, config) + + {:ok, + %{ + message + | usage_metadata: usage, + status: finish_reason, + metadata: Map.merge(message.metadata, metadata), + response_metadata: + message.response_metadata + |> Map.merge(metadata) + |> Map.merge(%{usage: usage, finish_reason: finish_reason}) + |> MessageParts.reject_nil_values() + }} + end + end + + def stream_body_to_message(_body, config, _opts) do + {:error, error(config, :invalid_response, "#{config.provider_name} stream body must be binary")} + end + + defp message_chunks(events, config) do + events + |> Enum.reduce(%{chunks: [], id: nil}, &apply_message_chunk_event(&1, &2, config)) + |> Map.fetch!(:chunks) + |> Enum.reverse() + end + + defp apply_message_chunk_event(%{"data" => %{"choices" => choices} = data}, state, config) + when is_list(choices) do + id = data["id"] || state.id + state = %{state | id: id} + + Enum.reduce(choices, state, fn choice, acc -> + delta = choice["delta"] || %{} + + acc + |> maybe_emit_reasoning(delta, id, config) + |> maybe_emit_content(delta, id, config) + |> maybe_emit_tool_calls(delta, id, config) + |> maybe_emit_unknown_delta(delta, id, config) + end) + end + + defp apply_message_chunk_event(_event, state, _config), do: state + + defp maybe_emit_reasoning(state, %{"reasoning_content" => reasoning}, id, config) + when is_binary(reasoning) do + block = + %{type: :reasoning, reasoning: reasoning} + |> put_optional(:index, Map.get(config, :reasoning_index)) + + emit_message_chunk( + state, + Messages.ai_chunk([block], + id: chunk_id(id, config), + metadata: %{reasoning_content: reasoning} + ) + ) + end + + defp maybe_emit_reasoning(state, _delta, _id, _config), do: state + + defp maybe_emit_content(state, %{"content" => content}, id, config) + when is_binary(content) do + emit_message_chunk(state, Messages.ai_chunk(content, id: chunk_id(id, config))) + end + + defp maybe_emit_content(state, _delta, _id, _config), do: state + + defp maybe_emit_tool_calls(state, %{"tool_calls" => calls}, id, config) + when is_list(calls) do + Enum.reduce(calls, state, fn call, acc -> + function = call["function"] || %{} + + emit_message_chunk( + acc, + Messages.ai_chunk("", + id: chunk_id(id, config), + tool_call_chunks: [ + Messages.tool_call_chunk( + id: call["id"], + index: call["index"], + name: function["name"], + args: function["arguments"] || "" + ) + ] + ) + ) + end) + end + + defp maybe_emit_tool_calls(state, _delta, _id, _config), do: state + + defp maybe_emit_unknown_delta(state, delta, id, config) when is_map(delta) do + unknown = Map.drop(delta, ["content", "reasoning_content", "tool_calls", "role"]) + + if unknown == %{} do + state + else + metadata = %{Map.fetch!(config, :unknown_delta_key) => unknown} + + emit_message_chunk( + state, + Messages.ai_chunk("", id: chunk_id(id, config), metadata: metadata) + ) + end + end + + defp emit_message_chunk(state, chunk), do: Map.update!(state, :chunks, &[chunk | &1]) + + defp chunk_to_events(%Messages.AIChunk{} = chunk, config) do + token_events = + if is_binary(chunk.content) and chunk.content != "" do + [%Events.Token{text: chunk.content}] + else + [] + end + + tool_call_events = Enum.map(chunk.tool_call_chunks || [], &%Events.ToolCallChunk{chunk: &1}) + metadata = event_metadata(config, reasoning_chunk?(chunk)) + events = token_events ++ tool_call_events ++ [%Events.MessageChunk{chunk: chunk}] + + Enum.map(events, &Stream.envelope(&1, metadata: metadata)) + end + + defp chunk_to_events(chunk, config) do + [Stream.envelope(%Events.MessageChunk{chunk: chunk}, metadata: event_metadata(config, false))] + end + + defp event_metadata(config, true), do: %{provider: config.provider, block_type: :reasoning} + defp event_metadata(config, false), do: %{provider: config.provider} + + defp reasoning_chunk?(%Messages.AIChunk{content: content}) when is_list(content) do + Enum.any?(content, fn + %{"type" => "reasoning"} -> true + %{type: :reasoning} -> true + _block -> false + end) + end + + defp reasoning_chunk?(_chunk), do: false + + defp done_event(%{"data" => %{"usage" => usage} = data}, _config) when is_map(usage) do + [%Events.Done{result: data, usage: usage}] + end + + defp done_event(%{"data" => %{"choices" => choices}}, config) when is_list(choices) do + case Enum.find(choices, &(get_in(&1, ["finish_reason"]) != nil)) do + nil -> [] + choice -> [%Events.Done{result: nil, usage: choice_usage(choice, config)}] + end + end + + defp done_event(_event, _config), do: [] + + defp choice_usage(choice, %{choice_usage: true}), do: choice["usage"] + defp choice_usage(_choice, _config), do: nil + + defp ensure_envelope(%Stream.Envelope{} = envelope, _config), do: envelope + + defp ensure_envelope(event, config) do + Stream.envelope(event, metadata: %{provider: config.provider}) + end + + defp stream_usage(events, config) do + Enum.reduce(events, nil, fn + %{"data" => %{"usage" => usage}}, _acc when is_map(usage) -> + config.usage_metadata.(%{"usage" => usage}) + + %{"data" => %{"choices" => choices}}, acc + when is_list(choices) and config.choice_usage == true -> + case Enum.find_value(choices, & &1["usage"]) do + usage when is_map(usage) -> config.usage_metadata.(%{"usage" => usage}) + _other -> acc + end + + _event, acc -> + acc + end) + end + + defp stream_finish_reason(events) do + Enum.find_value(events, fn + %{"data" => %{"choices" => choices}} when is_list(choices) -> + Enum.find_value(choices, & &1["finish_reason"]) + + _event -> + nil + end) + end + + defp maybe_put_message_id(message, metadata, %{include_chunk_id: true}) do + %{message | id: message.id || metadata[:id]} + end + + defp maybe_put_message_id(message, _metadata, _config), do: message + + defp chunk_id(id, %{include_chunk_id: true}), do: id + defp chunk_id(_id, _config), do: nil + + defp error(config, type, message) do + apply(config.error_module, :new, [type, message]) + end + + defp put_optional(map, _key, nil), do: map + defp put_optional(map, key, value), do: Map.put(map, key, value) +end diff --git a/lib/beam_weaver/provider/redacted_inspect.ex b/lib/beam_weaver/provider/redacted_inspect.ex new file mode 100644 index 0000000..cd5af0f --- /dev/null +++ b/lib/beam_weaver/provider/redacted_inspect.ex @@ -0,0 +1,23 @@ +defmodule BeamWeaver.Provider.RedactedInspect do + @moduledoc false + + import Inspect.Algebra + + alias BeamWeaver.Transport.Redactor + + @spec redacted_struct(struct(), Inspect.Opts.t()) :: Inspect.Algebra.t() + def redacted_struct(%module{} = struct, opts) do + fields = + struct + |> Map.from_struct() + |> Redactor.redact() + + concat(["#", module_name(module), "<", to_doc(fields, opts), ">"]) + end + + defp module_name(module) do + module + |> Atom.to_string() + |> String.trim_leading("Elixir.") + end +end diff --git a/lib/beam_weaver/provider/registry.ex b/lib/beam_weaver/provider/registry.ex index 5ea4b63..ac82b62 100644 --- a/lib/beam_weaver/provider/registry.ex +++ b/lib/beam_weaver/provider/registry.ex @@ -184,6 +184,7 @@ defmodule BeamWeaver.Provider.Registry do BeamWeaver.Google.Provider, BeamWeaver.Moonshot.Provider, BeamWeaver.ZAI.Provider, + BeamWeaver.DeepSeek.Provider, BeamWeaver.Provider.Fake ] |> Enum.filter(&Code.ensure_loaded?/1) diff --git a/lib/beam_weaver/provider/streaming.ex b/lib/beam_weaver/provider/streaming.ex index 4c2784d..abd8516 100644 --- a/lib/beam_weaver/provider/streaming.ex +++ b/lib/beam_weaver/provider/streaming.ex @@ -30,12 +30,14 @@ defmodule BeamWeaver.Provider.Streaming do end) case result do - {:ok, %Response{status: status}, buffer} when status in 200..299 -> + {:ok, %Response{status: status} = response, buffer} when status in 200..299 -> {events, _buffer} = SSE.process_chunk(buffer, "\n\n") emit_items(parser.(events), sink) + notify_response(response, opts) :ok {:ok, %Response{} = response, _buffer} -> + notify_response(response, opts) decode_stream_error({:ok, response}, error_decoder) {:error, error, _buffer} -> @@ -77,6 +79,13 @@ defmodule BeamWeaver.Provider.Streaming do defp emit_items(nil, _sink), do: :ok defp emit_items(item, sink), do: sink.(item) + defp notify_response(%Response{} = response, opts) do + case Keyword.get(opts, :on_response) do + callback when is_function(callback, 1) -> callback.(response) + _callback -> :ok + end + end + defp decode_stream_error(result, error_decoder) do case error_decoder.(result) do {:error, error} -> {:error, error} diff --git a/lib/beam_weaver/provider/structured_output.ex b/lib/beam_weaver/provider/structured_output.ex index ad24b14..936b961 100644 --- a/lib/beam_weaver/provider/structured_output.ex +++ b/lib/beam_weaver/provider/structured_output.ex @@ -49,12 +49,38 @@ defmodule BeamWeaver.Provider.StructuredOutput do cond do is_function(parser, 1) -> parser is_function(validator, 1) -> validator - true -> nil + true -> schema_parser(schema_from_format(format)) end end + defp parser_from_format({_name, schema}) when is_map(schema), do: schema_parser(schema) defp parser_from_format(_format), do: nil + defp schema_from_format(format) do + direct_schema = BeamWeaver.MapAccess.get(format, :schema) + nested_schema = BeamWeaver.MapAccess.get(format, :json_schema) + + cond do + is_map(direct_schema) -> direct_schema + is_map(nested_schema) -> schema_from_format(nested_schema) + json_object_schema?(format) -> format + true -> nil + end + end + + defp json_object_schema?(schema) do + BeamWeaver.MapAccess.get(schema, :type) in [:object, "object"] or + BeamWeaver.MapAccess.has_key?(schema, :properties) or + BeamWeaver.MapAccess.has_key?(schema, :required) + end + + defp schema_parser(schema) when is_map(schema) do + schema = BeamWeaver.MapShape.normalize_value(schema) + fn parsed -> BeamWeaver.OutputParser.validate_schema(schema, parsed) end + end + + defp schema_parser(_schema), do: nil + defp maybe_ensure_not_refusal(message, opts) do if Keyword.get(opts, :refusal?, false) do ensure_not_refusal(message, opts) diff --git a/lib/beam_weaver/tools/file_search/filesystem.ex b/lib/beam_weaver/tools/file_search/filesystem.ex index 4d92021..9644413 100644 --- a/lib/beam_weaver/tools/file_search/filesystem.ex +++ b/lib/beam_weaver/tools/file_search/filesystem.ex @@ -188,9 +188,6 @@ defmodule BeamWeaver.Tools.FileSearch.Filesystem do defp snippet(content, {:literal, regex}, max_bytes) do case Regex.run(regex, content, return: :index) do - {index, length} -> - snippet_at(content, index, length, max_bytes) - [{index, length} | _captures] -> snippet_at(content, index, length, max_bytes) diff --git a/lib/beam_weaver/tools/shell/command_runner.ex b/lib/beam_weaver/tools/shell/command_runner.ex new file mode 100644 index 0000000..ff2fd18 --- /dev/null +++ b/lib/beam_weaver/tools/shell/command_runner.ex @@ -0,0 +1,162 @@ +defmodule BeamWeaver.Tools.Shell.CommandRunner do + @moduledoc false + + @spec run(String.t(), keyword()) :: + {:ok, binary(), non_neg_integer()} | :timeout | {:error, term()} + def run(script, opts) when is_binary(script) do + marker = "__beam_weaver_shell_ready_#{System.unique_integer([:positive])}__" + command = instrument(script, marker, Keyword.get(opts, :stderr, :merge)) + deadline = deadline(Keyword.get(opts, :timeout, :infinity)) + + port = + Port.open( + {:spawn_executable, opts |> Keyword.fetch!(:shell) |> String.to_charlist()}, + port_options(command, opts) + ) + + Process.unlink(port) + + case await_ready(port, marker, "", deadline) do + {:ready, output} -> collect_output(port, [output], deadline) + {:exit, output, status} -> {:ok, output, status} + {:error, reason} -> {:error, reason} + {:timeout, buffered} -> timeout_after_readiness(port, marker, buffered) + end + rescue + exception -> {:error, exception} + catch + kind, reason -> {:error, {kind, reason}} + end + + defp timeout_after_readiness(port, marker, buffered) do + case await_ready(port, marker, buffered, :infinity) do + {:ready, _output} -> close_port(port) + {:exit, _output, _status} -> :ok + {:error, _reason} -> :ok + end + + :timeout + end + + defp await_ready(port, marker, buffered, deadline) do + case split_ready(buffered, marker) do + {:ok, output} -> + {:ready, output} + + :pending -> + case receive_port(port, remaining(deadline)) do + {:data, data} -> await_ready(port, marker, buffered <> data, deadline) + {:exit, status} -> {:exit, buffered, status} + {:error, reason} -> {:error, reason} + :timeout -> {:timeout, buffered} + end + end + end + + defp collect_output(port, chunks, deadline) do + case receive_port(port, remaining(deadline)) do + {:data, data} -> + collect_output(port, [data | chunks], deadline) + + {:exit, status} -> + {:ok, chunks |> Enum.reverse() |> IO.iodata_to_binary(), status} + + {:error, reason} -> + {:error, reason} + + :timeout -> + close_port(port) + :timeout + end + end + + defp receive_port(port, :infinity) do + receive do + {^port, {:data, data}} -> {:data, data} + {^port, {:exit_status, status}} -> {:exit, status} + {:EXIT, ^port, reason} -> {:error, reason} + end + end + + defp receive_port(port, timeout) do + receive do + {^port, {:data, data}} -> {:data, data} + {^port, {:exit_status, status}} -> {:exit, status} + {:EXIT, ^port, reason} -> {:error, reason} + after + timeout -> :timeout + end + end + + defp split_ready(buffered, marker) do + case :binary.match(buffered, marker) do + {offset, size} -> + prefix = binary_part(buffered, 0, offset) + suffix_offset = offset + size + suffix = binary_part(buffered, suffix_offset, byte_size(buffered) - suffix_offset) + {:ok, prefix <> suffix} + + :nomatch -> + :pending + end + end + + defp instrument(script, marker, stderr) do + marked = ["printf '%s' ", shell_quote(marker), "\neval ", shell_quote(script)] + + case stderr do + {:file, path} -> IO.iodata_to_binary(["exec 2> ", shell_quote(path), "\n", marked]) + :discard -> IO.iodata_to_binary(["exec 2> /dev/null\n", marked]) + :merge -> IO.iodata_to_binary(marked) + end + end + + defp port_options(command, opts) do + [ + :binary, + :exit_status, + :hide, + :in, + :use_stdio, + {:args, [~c"-c", String.to_charlist(command)]} + ] + |> maybe_put_stderr_to_stdout(Keyword.get(opts, :stderr, :merge)) + |> maybe_put_cd(Keyword.get(opts, :cd)) + |> maybe_put_env(Keyword.get(opts, :env, [])) + end + + defp maybe_put_stderr_to_stdout(options, :merge), do: [:stderr_to_stdout | options] + defp maybe_put_stderr_to_stdout(options, _stderr), do: options + + defp maybe_put_cd(options, nil), do: options + defp maybe_put_cd(options, cwd), do: [{:cd, String.to_charlist(cwd)} | options] + + defp maybe_put_env(options, []), do: options + + defp maybe_put_env(options, env) do + env = + Enum.map(env, fn {key, value} -> + {key |> to_string() |> String.to_charlist(), value |> to_string() |> String.to_charlist()} + end) + + [{:env, env} | options] + end + + defp deadline(nil), do: :infinity + defp deadline(:infinity), do: :infinity + defp deadline(timeout), do: System.monotonic_time(:millisecond) + timeout + + defp remaining(:infinity), do: :infinity + defp remaining(deadline), do: max(deadline - System.monotonic_time(:millisecond), 0) + + defp close_port(port) do + if Port.info(port), do: Port.close(port) + :ok + rescue + ArgumentError -> :ok + end + + defp shell_quote(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end +end diff --git a/lib/beam_weaver/tools/shell/host_executor.ex b/lib/beam_weaver/tools/shell/host_executor.ex index 751b4a0..2ead3fb 100644 --- a/lib/beam_weaver/tools/shell/host_executor.ex +++ b/lib/beam_weaver/tools/shell/host_executor.ex @@ -8,6 +8,7 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do alias BeamWeaver.Core.Error alias BeamWeaver.Core.ID alias BeamWeaver.ShellPolicy + alias BeamWeaver.Tools.Shell.CommandRunner alias BeamWeaver.Tracing.Redactor @impl true @@ -20,23 +21,25 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do end defp run_allowed(command, policy, opts) do - {shell_command, after_run, scratch} = prepare_command(command, policy) + {stderr, after_run, scratch} = prepare_stderr(policy) metadata = command_metadata(:host, command, policy.timeout, opts) - task = - Task.async(fn -> - {output, status} = System.cmd(shell(), ["-c", shell_command], system_opts(policy)) - {output, status, after_run.()} - end) + case CommandRunner.run(command, + shell: shell(), + cd: policy.cwd, + env: env(policy), + stderr: stderr, + timeout: policy.timeout + ) do + {:ok, output, status} -> + stderr = after_run.() - case Task.yield(task, yield_timeout(policy.timeout)) || Task.shutdown(task, :brutal_kill) do - {:ok, {output, status, stderr}} -> {:ok, command |> base_result(status, output, policy, Map.put(metadata, :exit_code, status)) |> maybe_put_stderr(stderr, policy)} - nil -> + :timeout -> cleanup_scratch(scratch) {:error, @@ -45,7 +48,7 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do metadata: Map.merge(metadata, %{kill_attempted: true, error: "timeout"}) })} - {:exit, reason} -> + {:error, reason} -> cleanup_scratch(scratch) {:error, @@ -60,18 +63,7 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do defp cleanup_scratch(nil), do: :ok defp cleanup_scratch(path), do: File.rm_rf(path) - defp yield_timeout(nil), do: :infinity - defp yield_timeout(:infinity), do: :infinity - defp yield_timeout(timeout), do: timeout - - defp system_opts(policy) do - [] - |> maybe_put_cd(policy.cwd) - |> Keyword.put(:stderr_to_stdout, policy.stderr == :merge) - |> Keyword.put(:env, env(policy)) - end - - defp prepare_command(command, %ShellPolicy{stderr: :separate}) do + defp prepare_stderr(%ShellPolicy{stderr: :separate}) do scratch = Path.join( System.tmp_dir!(), @@ -80,9 +72,8 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do File.mkdir_p!(scratch) path = Path.join(scratch, "stderr") - shell_command = "(" <> command <> ") 2> " <> shell_quote(path) - {shell_command, + {{:file, path}, fn -> stderr = case File.read(path) do @@ -95,11 +86,9 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do end, scratch} end - defp prepare_command(command, %ShellPolicy{stderr: :discard}) do - {"(" <> command <> ") 2> /dev/null", fn -> nil end, nil} - end + defp prepare_stderr(%ShellPolicy{stderr: :discard}), do: {:discard, fn -> nil end, nil} - defp prepare_command(command, _policy), do: {command, fn -> nil end, nil} + defp prepare_stderr(_policy), do: {:merge, fn -> nil end, nil} defp base_result(command, status, output, policy, metadata) do %{ @@ -115,9 +104,6 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do defp maybe_put_stderr(result, stderr, policy), do: Map.put(result, :stderr, format_output(stderr, policy)) - defp maybe_put_cd(opts, nil), do: opts - defp maybe_put_cd(opts, cwd), do: Keyword.put(opts, :cd, cwd) - defp env(policy) do policy.env |> Enum.filter(fn {key, _value} -> @@ -134,7 +120,7 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do end defp redact(output, redactions) do - Enum.reduce(redactions, output || "", fn {regex, replacement}, acc -> + Enum.reduce(redactions, output, fn {regex, replacement}, acc -> Regex.replace(regex, acc, replacement) end) end @@ -153,10 +139,6 @@ defmodule BeamWeaver.Tools.Shell.HostExecutor do binary_part(output, 0, max_bytes) <> indicator end - defp shell_quote(value) do - "'" <> String.replace(value, "'", "'\"'\"'") <> "'" - end - defp shell, do: System.find_executable("sh") || "/bin/sh" defp command_metadata(backend, command, timeout, opts) do diff --git a/lib/beam_weaver/tools/shell/session.ex b/lib/beam_weaver/tools/shell/session.ex index 56525fe..1a54dc2 100644 --- a/lib/beam_weaver/tools/shell/session.ex +++ b/lib/beam_weaver/tools/shell/session.ex @@ -13,6 +13,7 @@ defmodule BeamWeaver.Tools.Shell.Session do alias BeamWeaver.Core.Error alias BeamWeaver.Core.ID alias BeamWeaver.ShellPolicy + alias BeamWeaver.Tools.Shell.CommandRunner alias BeamWeaver.Tracing.Redactor defstruct [ @@ -213,26 +214,25 @@ defmodule BeamWeaver.Tools.Shell.Session do env_path = Path.join(scratch, "env") stderr_path = Path.join(scratch, "stderr") - task = - Task.async(fn -> - script = session_script(command, metadata_path, env_path) - script = redirect_stderr(script, stderr_path, state.policy) - - {output, status} = - System.cmd(shell(), ["-c", script], - cd: state.cwd, - env: env_list(state.env), - stderr_to_stdout: state.policy.stderr == :merge - ) - - metadata = read_metadata(metadata_path) - env = read_env(env_path, state.env) - stderr = read_stderr(stderr_path, state.policy) - - {output, status, metadata, env, stderr} - end) + result = + case CommandRunner.run(session_script(command, metadata_path, env_path), + shell: shell(), + cd: state.cwd, + env: env_list(state.env), + stderr: stderr_option(stderr_path, state.policy), + timeout: timeout + ) do + {:ok, output, status} -> + metadata = read_metadata(metadata_path) + env = read_env(env_path, state.env) + stderr = read_stderr(stderr_path, state.policy) + + {:ok, {output, status, metadata, env, stderr}} + + other -> + other + end - result = Task.yield(task, yield_timeout(timeout)) || Task.shutdown(task, :brutal_kill) File.rm_rf(scratch) case result do @@ -249,14 +249,14 @@ defmodule BeamWeaver.Tools.Shell.Session do |> base_result(status, output, state.policy, Map.put(command_metadata, :exit_code, status)) |> maybe_put_stderr(stderr, state.policy), state} - nil -> + :timeout -> {:error, Error.new(:shell_timeout, "shell command timed out", %{ command: command, metadata: Map.merge(command_metadata, %{kill_attempted: true, error: "timeout"}) }), state} - {:exit, reason} -> + {:error, reason} -> {:error, Error.new(:shell_execution_error, "shell command failed", %{ command: command, @@ -284,13 +284,9 @@ defmodule BeamWeaver.Tools.Shell.Session do |> IO.iodata_to_binary() end - defp redirect_stderr(script, path, %ShellPolicy{stderr: :separate}), - do: "(" <> script <> ") 2> " <> shell_quote(path) - - defp redirect_stderr(script, _path, %ShellPolicy{stderr: :discard}), - do: "(" <> script <> ") 2> /dev/null" - - defp redirect_stderr(script, _path, _policy), do: script + defp stderr_option(path, %ShellPolicy{stderr: :separate}), do: {:file, path} + defp stderr_option(_path, %ShellPolicy{stderr: :discard}), do: :discard + defp stderr_option(_path, _policy), do: :merge defp base_result(command, status, output, policy, metadata) do %{ @@ -358,7 +354,7 @@ defmodule BeamWeaver.Tools.Shell.Session do end defp redact(output, redactions) do - Enum.reduce(redactions, output || "", fn {regex, replacement}, acc -> + Enum.reduce(redactions, output, fn {regex, replacement}, acc -> Regex.replace(regex, acc, replacement) end) end @@ -406,10 +402,6 @@ defmodule BeamWeaver.Tools.Shell.Session do :exit, _reason -> :infinity end - defp yield_timeout(nil), do: :infinity - defp yield_timeout(:infinity), do: :infinity - defp yield_timeout(timeout), do: timeout - defp scratch_dir do dir = Path.join( diff --git a/lib/beam_weaver/transport/req_finch.ex b/lib/beam_weaver/transport/req_finch.ex index 0907202..1351825 100644 --- a/lib/beam_weaver/transport/req_finch.ex +++ b/lib/beam_weaver/transport/req_finch.ex @@ -61,7 +61,10 @@ defmodule BeamWeaver.Transport.ReqFinch do method: request.method, url: request.url, headers: request.headers, - finch: Keyword.get(opts, :finch, BeamWeaver.Transport.Finch), + finch: + opts + |> Keyword.get(:finch, BeamWeaver.Transport.Finch) + |> normalize_finch_options(), receive_timeout: Keyword.get(opts, :timeout, Keyword.get(request.options, :timeout, 15_000)), retry: false, redirect: false @@ -70,6 +73,11 @@ defmodule BeamWeaver.Transport.ReqFinch do |> maybe_put_body(request) end + defp normalize_finch_options(nil), do: nil + defp normalize_finch_options(name) when is_atom(name), do: [name: name] + defp normalize_finch_options(options) when is_list(options), do: options + defp normalize_finch_options(options), do: options + defp maybe_put_finch_private(options, opts) do private = opts diff --git a/lib/beam_weaver/zai/messages.ex b/lib/beam_weaver/zai/messages.ex index 83b4656..3d2a8da 100644 --- a/lib/beam_weaver/zai/messages.ex +++ b/lib/beam_weaver/zai/messages.ex @@ -6,14 +6,12 @@ defmodule BeamWeaver.ZAI.Messages do alias BeamWeaver.Core.ContentBlock alias BeamWeaver.Core.Message alias BeamWeaver.Core.Messages + alias BeamWeaver.Models.ProfileRegistry.ZAI, as: ZAIProfiles + alias BeamWeaver.Models.UsageCost alias BeamWeaver.OpenAI.MessageParts alias BeamWeaver.Result alias BeamWeaver.ZAI.Error - @input_price_per_mtok 1.40 - @cached_input_price_per_mtok 0.26 - @output_price_per_mtok 4.40 - @spec to_chat_messages([Message.t()]) :: {:ok, [map()]} | {:error, Error.t()} def to_chat_messages(messages) when is_list(messages) do Result.traverse(messages, &to_chat_message/1) @@ -92,29 +90,15 @@ defmodule BeamWeaver.ZAI.Messages do input_tokens = usage["prompt_tokens"] || usage["input_tokens"] || 0 output_tokens = usage["completion_tokens"] || usage["output_tokens"] || 0 total_tokens = usage["total_tokens"] || input_tokens + output_tokens - cached_tokens = cached_tokens(usage) - uncached_input_tokens = max(input_tokens - cached_tokens, 0) - input_cost = mtok_cost(uncached_input_tokens, @input_price_per_mtok) - cached_input_cost = mtok_cost(cached_tokens, @cached_input_price_per_mtok) - output_cost = mtok_cost(output_tokens, @output_price_per_mtok) %{ input_tokens: input_tokens, output_tokens: output_tokens, total_tokens: total_tokens, - input_cost: input_cost + cached_input_cost, - output_cost: output_cost, - total_cost: input_cost + cached_input_cost + output_cost, - input_cost_details: %{ - uncached: input_cost, - cache_read: cached_input_cost - }, - output_cost_details: %{ - text: output_cost - }, input_token_details: input_token_details(usage), output_token_details: output_token_details(usage) } + |> Map.merge(UsageCost.calculate(pricing_profile(), usage) || %{}) |> BeamWeaver.MapShape.reject_nil_or_empty() end @@ -389,12 +373,6 @@ defmodule BeamWeaver.ZAI.Messages do |> BeamWeaver.MapShape.reject_nil_or_empty() end - defp mtok_cost(tokens, price_per_mtok) when is_number(tokens) do - tokens * price_per_mtok / 1_000_000 - end - - defp mtok_cost(_tokens, _price_per_mtok), do: 0 - defp positive(value) when is_number(value) and value > 0, do: value defp positive(_value), do: nil @@ -447,4 +425,9 @@ defmodule BeamWeaver.ZAI.Messages do defp put_optional(map, _key, nil), do: map defp put_optional(map, _key, []), do: map defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp pricing_profile do + {:ok, profile} = ZAIProfiles.resolve("glm-5.2") + profile + end end diff --git a/lib/beam_weaver/zai/options.ex b/lib/beam_weaver/zai/options.ex index 5561b83..65ae132 100644 --- a/lib/beam_weaver/zai/options.ex +++ b/lib/beam_weaver/zai/options.ex @@ -1,9 +1,9 @@ defmodule BeamWeaver.ZAI.Options do @moduledoc false - alias BeamWeaver.JSON alias BeamWeaver.Models.ParamPolicy alias BeamWeaver.OpenAI.MessageParts + alias BeamWeaver.Provider.JsonObjectFormat alias BeamWeaver.ZAI.Error alias BeamWeaver.ZAI.Messages @@ -16,7 +16,7 @@ defmodule BeamWeaver.ZAI.Options do with :ok <- validate_profile_params(model, opts), {:ok, zai_messages} <- Messages.to_chat_messages(messages), {:ok, response_format, schema_instruction} <- response_format(model, opts), - zai_messages <- maybe_inject_schema_instruction(zai_messages, schema_instruction), + zai_messages <- JsonObjectFormat.inject_instruction(zai_messages, schema_instruction), {:ok, body} <- build_body(model, zai_messages, response_format, opts), :ok <- validate_model(body), :ok <- validate_thinking(body), @@ -68,71 +68,12 @@ defmodule BeamWeaver.ZAI.Options do end defp response_format(model, opts) do - case option(model, opts, :response_format) || option(model, opts, :structured_output) do - nil -> - {:ok, nil, nil} + format = option(model, opts, :response_format) || option(model, opts, :structured_output) - %{"type" => "json_object"} -> - {:ok, %{"type" => "json_object"}, nil} - - %{type: type} when type in [:json_object, "json_object"] -> - {:ok, %{"type" => "json_object"}, nil} - - %{name: name, schema: schema} = format when is_binary(name) and is_map(schema) -> - {:ok, %{"type" => "json_object"}, schema_instruction(name, schema, Map.get(format, :strict))} - - %{"name" => name, "schema" => schema} = format when is_binary(name) and is_map(schema) -> - {:ok, %{"type" => "json_object"}, schema_instruction(name, schema, Map.get(format, "strict"))} - - {name, schema} when is_binary(name) and is_map(schema) -> - {:ok, %{"type" => "json_object"}, schema_instruction(name, schema, nil)} - - other -> - {:error, - Error.new(:invalid_response_format, "Z.ai GLM-5.2 supports JSON object response_format only", %{ - response_format: inspect(other), - supported: [%{"type" => "json_object"}] - })} - end - end - - defp schema_instruction(name, schema, strict) do - %{ - "role" => "system", - "content" => schema_instruction_text(name, schema, strict) - } - end - - defp schema_instruction_text(name, schema, strict) do - required = schema |> BeamWeaver.MapAccess.get(:required, []) |> List.wrap() - required_text = if required == [], do: "none", else: Enum.map_join(required, ", ", &to_string/1) - - strict_text = - if strict == false, do: "Validate against this schema.", else: "Strictly validate against this schema." - - """ - BeamWeaver structured output contract: - - Return exactly one JSON object and no markdown, prose, code fences, or commentary. - - #{strict_text} - - Include every required key. Required keys: #{required_text}. - - If information is unavailable, still include the key with a value compatible with the schema. - - Schema name: #{name} - JSON Schema: - #{JSON.encode!(schema, pretty: true)} - """ - |> String.trim() - end - - defp maybe_inject_schema_instruction(messages, nil), do: messages - - defp maybe_inject_schema_instruction(messages, instruction) do - {system_messages, rest} = - Enum.split_while(messages, fn message -> - Map.get(message, "role") == "system" - end) - - system_messages ++ [instruction] ++ rest + JsonObjectFormat.normalize(format, + error_module: Error, + error_message: "Z.ai GLM-5.2 supports JSON object response_format only" + ) end defp validate_profile_params(model, opts) do diff --git a/lib/beam_weaver/zai/streaming.ex b/lib/beam_weaver/zai/streaming.ex index a076200..54bc01c 100644 --- a/lib/beam_weaver/zai/streaming.ex +++ b/lib/beam_weaver/zai/streaming.ex @@ -1,239 +1,22 @@ defmodule BeamWeaver.ZAI.Streaming do @moduledoc false - alias BeamWeaver.Core.Messages - alias BeamWeaver.Core.Messages.MessageChunk - alias BeamWeaver.OpenAI.MessageParts - alias BeamWeaver.OpenAI.Streaming.SSE - alias BeamWeaver.Stream - alias BeamWeaver.Stream.Events + alias BeamWeaver.Provider.OpenAICompatibleStreaming alias BeamWeaver.ZAI.Error alias BeamWeaver.ZAI.Messages, as: ZAIMessages @spec text_deltas(binary() | [map()] | term()) :: [String.t()] - def text_deltas(body) when is_binary(body), do: body |> SSE.events() |> text_deltas() + def text_deltas(body), do: OpenAICompatibleStreaming.text_deltas(body) - def text_deltas(events) when is_list(events) do - events - |> Enum.flat_map(fn - %{"data" => %{"choices" => choices}} when is_list(choices) -> - Enum.flat_map(choices, fn choice -> - case get_in(choice, ["delta", "content"]) do - text when is_binary(text) -> [text] - _other -> [] - end - end) - - _event -> - [] - end) - end - - def text_deltas(_body), do: [] - - @spec typed_events(binary() | [map()] | term()) :: [Stream.Envelope.t()] - def typed_events(body) when is_binary(body), do: body |> SSE.events() |> typed_events() - - def typed_events(events) when is_list(events) do - chunk_events = - events - |> message_chunks() - |> Enum.flat_map(&chunk_to_events/1) - - done_events = Enum.flat_map(events, &done_event/1) - - (chunk_events ++ done_events) - |> Enum.map(&ensure_envelope/1) - end - - def typed_events(_body), do: [] + @spec typed_events(binary() | [map()] | term()) :: [BeamWeaver.Stream.Envelope.t()] + def typed_events(body), do: OpenAICompatibleStreaming.typed_events(body, config()) @spec stream_body_to_message(binary(), keyword()) :: {:ok, BeamWeaver.Core.Message.t()} | {:error, Error.t()} def stream_body_to_message(body, opts \\ []) - def stream_body_to_message(body, opts) when is_binary(body) do - events = SSE.events(body) - chunks = message_chunks(events) - - case MessageChunk.merge_many(chunks) do - nil -> - {:error, Error.new(:invalid_response, "Z.ai chat-completions stream had no chunks")} - - chunk -> - message = MessageChunk.to_message(chunk) - usage = stream_usage(events) - finish_reason = stream_finish_reason(events) - metadata = stream_metadata(events, message, opts) - - {:ok, - %{ - message - | id: message.id || metadata[:id], - usage_metadata: usage, - status: finish_reason, - metadata: Map.merge(message.metadata, metadata), - response_metadata: - message.response_metadata - |> Map.merge(metadata) - |> Map.merge(%{usage: usage, finish_reason: finish_reason}) - |> MessageParts.reject_nil_values() - }} - end - end - - def stream_body_to_message(_body, _opts) do - {:error, Error.new(:invalid_response, "Z.ai chat-completions stream body must be binary")} - end - - defp message_chunks(events) when is_list(events) do - events - |> Enum.reduce(%{chunks: [], id: nil}, &apply_message_chunk_event/2) - |> Map.fetch!(:chunks) - |> Enum.reverse() - end - - defp apply_message_chunk_event(%{"data" => %{"choices" => choices} = data}, state) - when is_list(choices) do - id = data["id"] || state.id - state = %{state | id: id} - - Enum.reduce(choices, state, fn choice, acc -> - delta = choice["delta"] || %{} - - acc - |> maybe_emit_reasoning(delta, id) - |> maybe_emit_content(delta, id) - |> maybe_emit_tool_calls(delta, id) - |> maybe_emit_unknown_delta(delta, id) - end) - end - - defp apply_message_chunk_event(_event, state), do: state - - defp maybe_emit_reasoning(state, %{"reasoning_content" => reasoning}, id) - when is_binary(reasoning) do - emit_message_chunk( - state, - Messages.ai_chunk([%{type: :reasoning, reasoning: reasoning, index: 0}], - id: id, - metadata: %{reasoning_content: reasoning} - ) - ) - end - - defp maybe_emit_reasoning(state, _delta, _id), do: state - - defp maybe_emit_content(state, %{"content" => content}, id) when is_binary(content), - do: emit_message_chunk(state, Messages.ai_chunk(content, id: id)) - - defp maybe_emit_content(state, _delta, _id), do: state - - defp maybe_emit_tool_calls(state, %{"tool_calls" => calls}, id) when is_list(calls) do - Enum.reduce(calls, state, fn call, acc -> - function = call["function"] || %{} - - emit_message_chunk( - acc, - Messages.ai_chunk("", - id: id, - tool_call_chunks: [ - Messages.tool_call_chunk( - id: call["id"], - index: call["index"], - name: function["name"], - args: function["arguments"] || "" - ) - ] - ) - ) - end) - end - - defp maybe_emit_tool_calls(state, _delta, _id), do: state - - defp maybe_emit_unknown_delta(state, delta, id) when is_map(delta) do - unknown = Map.drop(delta, ["content", "reasoning_content", "tool_calls", "role"]) - - if unknown == %{} do - state - else - emit_message_chunk(state, Messages.ai_chunk("", id: id, metadata: %{zai_delta: unknown})) - end - end - - defp emit_message_chunk(state, chunk), do: Map.update!(state, :chunks, &[chunk | &1]) - - defp chunk_to_events(%Messages.AIChunk{} = chunk) do - token_events = - if is_binary(chunk.content) and chunk.content != "" do - [%Events.Token{text: chunk.content}] - else - [] - end - - reasoning? = reasoning_chunk?(chunk) - tool_call_events = Enum.map(chunk.tool_call_chunks || [], &%Events.ToolCallChunk{chunk: &1}) - - metadata = - if reasoning? do - %{provider: :zai, block_type: :reasoning} - else - %{provider: :zai} - end - - events = token_events ++ tool_call_events ++ [%Events.MessageChunk{chunk: chunk}] - Enum.map(events, &Stream.envelope(&1, metadata: metadata)) - end - - defp chunk_to_events(chunk), - do: [Stream.envelope(%Events.MessageChunk{chunk: chunk}, metadata: %{provider: :zai})] - - defp reasoning_chunk?(%Messages.AIChunk{content: content}) when is_list(content) do - Enum.any?(content, fn - %{"type" => "reasoning"} -> true - %{type: :reasoning} -> true - _block -> false - end) - end - - defp reasoning_chunk?(_chunk), do: false - - defp done_event(%{"data" => %{"usage" => usage} = data}) when is_map(usage) do - [%Events.Done{result: data, usage: usage}] - end - - defp done_event(%{"data" => %{"choices" => choices}}) when is_list(choices) do - if Enum.any?(choices, &(get_in(&1, ["finish_reason"]) != nil)), - do: [%Events.Done{result: nil}], - else: [] - end - - defp done_event(_event), do: [] - - defp ensure_envelope(%Stream.Envelope{} = envelope), do: envelope - defp ensure_envelope(event), do: Stream.envelope(event, metadata: %{provider: :zai}) - - defp stream_usage(events) do - events - |> Enum.reduce(nil, fn - %{"data" => %{"usage" => usage}}, _acc when is_map(usage) -> - ZAIMessages.usage_metadata(%{"usage" => usage}) - - _event, acc -> - acc - end) - end - - defp stream_finish_reason(events) do - events - |> Enum.find_value(fn - %{"data" => %{"choices" => choices}} when is_list(choices) -> - Enum.find_value(choices, & &1["finish_reason"]) - - _event -> - nil - end) + def stream_body_to_message(body, opts) do + OpenAICompatibleStreaming.stream_body_to_message(body, config(), opts) end defp stream_metadata(events, message, opts) do @@ -304,4 +87,18 @@ defmodule BeamWeaver.ZAI.Streaming do defp put_optional(map, _key, nil), do: map defp put_optional(map, _key, []), do: map defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp config do + %{ + provider: :zai, + provider_name: "Z.ai chat-completions", + error_module: Error, + usage_metadata: &ZAIMessages.usage_metadata/1, + stream_metadata: &stream_metadata/3, + choice_usage: false, + include_chunk_id: true, + reasoning_index: 0, + unknown_delta_key: :zai_delta + } + end end diff --git a/lib/mix/tasks/beam_weaver.providers.matrix.ex b/lib/mix/tasks/beam_weaver.providers.matrix.ex index e51a7cd..f0f9650 100644 --- a/lib/mix/tasks/beam_weaver.providers.matrix.ex +++ b/lib/mix/tasks/beam_weaver.providers.matrix.ex @@ -36,7 +36,7 @@ defmodule Mix.Tasks.BeamWeaver.Providers.Matrix do defp check!(matrix) do providers = matrix |> Enum.map(& &1.provider) |> Enum.uniq() |> Enum.sort() - required = [:anthropic, :fake, :google, :openai, :xai, :zai] + required = [:anthropic, :deepseek, :fake, :google, :openai, :xai, :zai] missing = required -- providers case missing do diff --git a/mix.exs b/mix.exs index 60b007a..0e45da4 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule BeamWeaver.MixProject do def project do [ app: :beam_weaver, - version: "0.1.14", + version: "0.1.15", description: "Elixir-native LangChain, LangGraph, and DeepAgents for traceable LLM apps: OTP workflows, tools, memory, human-in-the-loop, streaming, custom clients/adapters, minimal deps, and WeaveScope tracing.", source_url: "https://github.com/caudena/beam_weaver", @@ -31,7 +31,7 @@ defmodule BeamWeaver.MixProject do {:ex_doc, "~> 0.40", only: :dev, runtime: false}, {:ecto_sql, "~> 3.13"}, {:postgrex, "~> 0.20"}, - {:req, "~> 0.6.1"}, + {:req, "~> 0.7.2"}, {:finch, "~> 0.23.0"}, {:fastest_tiktoken, "~> 0.1.1"}, {:telemetry, "~> 1.2"}, @@ -92,6 +92,7 @@ defmodule BeamWeaver.MixProject do "docs/partners/openai.md", "docs/partners/anthropic.md", "docs/partners/google.md", + "docs/partners/deepseek.md", "docs/partners/moonshot.md", "docs/partners/xai.md", "docs/partners/zai.md", @@ -157,6 +158,7 @@ defmodule BeamWeaver.MixProject do "docs/partners/openai.md", "docs/partners/anthropic.md", "docs/partners/google.md", + "docs/partners/deepseek.md", "docs/partners/moonshot.md", "docs/partners/xai.md", "docs/partners/zai.md" diff --git a/mix.lock b/mix.lock index 71374d1..65dfc04 100644 --- a/mix.lock +++ b/mix.lock @@ -18,7 +18,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"}, - "req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"}, + "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/scripts/capture_deepseek_live.exs b/scripts/capture_deepseek_live.exs new file mode 100644 index 0000000..91d7574 --- /dev/null +++ b/scripts/capture_deepseek_live.exs @@ -0,0 +1,767 @@ +Application.ensure_all_started(:ex_unit) +Application.ensure_all_started(:beam_weaver) + +Code.require_file(Path.expand("../support/provider_conformance.exs", __DIR__)) + +defmodule BeamWeaver.DeepSeekLiveCapture do + @moduledoc false + + alias BeamWeaver.DeepSeek.Client + alias BeamWeaver.Stream.Events + alias BeamWeaver.TestSupport.ProviderConformance.CaptureTransport + + @capture_guard "BEAM_WEAVER_CAPTURE_DEEPSEEK" + @api_key_env "DEEPSEEK_API_KEY" + @capture_dir_env "DEEPSEEK_CAPTURE_DIR" + @timeout 120_000 + @flash "deepseek-v4-flash" + @pro "deepseek-v4-pro" + + def run do + with :ok <- require_capture_guard(), + {:ok, api_key} <- required_api_key() do + context = %{api_key: api_key, output_dir: output_dir!()} + File.mkdir_p!(context.output_dir) + + entries = [] + entries = capture_account_surface(context, entries) + entries = Enum.reduce([@flash, @pro], entries, &capture_chat_model(context, &2, &1)) + entries = Enum.reduce([@flash, @pro], entries, &capture_fim_model(context, &2, &1)) + entries = capture_responses(context, entries) + entries = capture_anthropic(context, entries) + entries = Enum.reverse(entries) + manifest_path = write_manifest!(context, entries) + + case Enum.reject(entries, &successful_entry?/1) do + [] -> + Mix.shell().info("DeepSeek live capture complete: #{length(entries)} scenarios -> #{manifest_path}") + + failures -> + Mix.shell().error( + "DeepSeek live capture recorded #{length(failures)} unexpected failure(s) -> #{manifest_path}" + ) + + System.halt(1) + end + else + {:error, :capture_disabled} -> + Mix.shell().info("DeepSeek live capture is disabled; set #{@capture_guard}=true to enable it.") + + {:error, :missing_api_key} -> + Mix.shell().error("DeepSeek live capture requires #{@api_key_env}.") + System.halt(1) + end + end + + defp capture_account_surface(context, entries) do + {_result, entries} = capture(context, entries, "models", &Client.models/1) + {_result, entries} = capture(context, entries, "balance", &Client.balance/1) + + {_result, entries} = + capture( + context, + entries, + "invalid_request", + &Client.chat_completions(&1, %{"messages" => []}), + expected_error: true + ) + + {_result, entries} = + capture( + context, + entries, + "invalid_auth", + &Client.chat_completions(&1, basic_chat_body(@flash)), + api_key: "deepseek-invalid-capture-key", + expected_error: true + ) + + entries + end + + defp capture_chat_model(context, entries, model) do + slug = model_slug(model) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_basic", + &Client.chat_completions(&1, basic_chat_body(model)) + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_thinking", + &Client.chat_completions(&1, thinking_chat_body(model)) + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_stream", + &Client.chat_completions_stream(&1, streaming_chat_body(model)), + mode: :stream + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_json", + &Client.chat_completions(&1, json_chat_body(model)) + ) + + cache_body = cache_chat_body(model) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_cache_first", + &Client.chat_completions(&1, cache_body) + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_cache_second", + &Client.chat_completions(&1, cache_body) + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_prefix", + &Client.chat_completions(&1, prefix_chat_body(model)) + ) + + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_strict_tool", + &Client.chat_completions(&1, strict_tool_chat_body(model)) + ) + + capture_chat_tool_replay(context, entries, model, slug) + end + + defp capture_chat_tool_replay(context, entries, model, slug) do + prerequisite_body = thinking_tool_chat_body(model) + + {result, entries} = + capture( + context, + entries, + "chat_#{slug}_tool_call", + &Client.chat_completions(&1, prerequisite_body) + ) + + case chat_tool_replay_body(result, prerequisite_body) do + {:ok, replay_body} -> + {_result, entries} = + capture( + context, + entries, + "chat_#{slug}_tool_replay", + &Client.chat_completions(&1, replay_body) + ) + + entries + + {:error, reason} -> + record_scenario_failure(context, entries, "chat_#{slug}_tool_replay", reason) + end + end + + defp capture_fim_model(context, entries, model) do + slug = model_slug(model) + + {_result, entries} = + capture( + context, + entries, + "fim_#{slug}_sync", + &Client.completions(&1, fim_body(model)) + ) + + {_result, entries} = + capture( + context, + entries, + "fim_#{slug}_stream", + &Client.completions_stream(&1, Map.put(fim_body(model), "stream", true)), + mode: :stream + ) + + entries + end + + defp capture_responses(context, entries) do + scenarios = [ + {"responses_flash_sync", responses_basic_body(), []}, + {"responses_flash_stream", Map.put(responses_basic_body(), "stream", true), mode: :stream}, + {"responses_flash_schema", responses_schema_body(), []}, + {"responses_flash_function", responses_function_body(), []}, + {"responses_flash_forced_function_no_thinking", responses_forced_function_body(), []}, + {"responses_flash_web_search", responses_web_search_body(), []}, + {"responses_flash_apply_patch", responses_apply_patch_body(), []}, + {"responses_pro_expected_rejection", Map.put(responses_basic_body(), "model", @pro), expected_error: true} + ] + + Enum.reduce(scenarios, entries, fn {scenario, body, opts}, acc -> + request = + if Keyword.get(opts, :mode) == :stream, + do: &Client.responses_stream(&1, body), + else: &Client.responses(&1, body) + + {_result, acc} = + capture( + context, + acc, + scenario, + request, + Keyword.put(opts, :provider, :deepseek_responses) + ) + + acc + end) + end + + defp capture_anthropic(context, entries) do + aliases = [ + {"sonnet_flash", "claude-sonnet-4-5"}, + {"opus_pro", "claude-opus-4-1"} + ] + + Enum.reduce(aliases, entries, fn {slug, model}, acc -> + {_result, acc} = + capture( + context, + acc, + "anthropic_#{slug}_sync", + &Client.anthropic_messages(&1, anthropic_basic_body(model)) + ) + + {_result, acc} = + capture( + context, + acc, + "anthropic_#{slug}_stream", + &Client.anthropic_messages_stream( + &1, + Map.put(anthropic_basic_body(model), "stream", true) + ), + mode: :stream + ) + + {_result, acc} = + capture( + context, + acc, + "anthropic_#{slug}_thinking", + &Client.anthropic_messages(&1, anthropic_thinking_body(model)) + ) + + {_result, acc} = + capture( + context, + acc, + "anthropic_#{slug}_tools", + &Client.anthropic_messages(&1, anthropic_tool_body(model)) + ) + + acc + end) + end + + defp capture(context, entries, scenario, request, opts \\ []) do + path = capture_path(context, scenario) + client = capture_client(context, scenario, path, opts) + + try do + result = request.(client) + result = maybe_consume_stream(result, Keyword.get(opts, :mode, :sync)) + status = result_status(result, Keyword.get(opts, :expected_error, false)) + entry = capture_entry(scenario, status, path) + print_summary(entry) + {result, [entry | entries]} + rescue + exception -> + failure = "exception_#{exception.__struct__ |> Module.split() |> List.last()}" + failure_path = ensure_failure_record(context, scenario, path, failure) + entry = capture_entry(scenario, "failed_#{failure}", failure_path) + print_summary(entry) + {{:capture_exception, exception.__struct__}, [entry | entries]} + catch + kind, _reason -> + failure = "caught_#{kind}" + failure_path = ensure_failure_record(context, scenario, path, failure) + entry = capture_entry(scenario, "failed_#{failure}", failure_path) + print_summary(entry) + {{:capture_caught, kind}, [entry | entries]} + end + end + + defp maybe_consume_stream(result, :sync), do: result + + defp maybe_consume_stream({:ok, enumerable}, :stream) do + items = Enum.to_list(enumerable) + + case Enum.find_value(items, fn + %Events.Error{error: error} -> error + _item -> nil + end) do + nil -> {:ok, %{stream_items: length(items)}} + error -> {:error, error} + end + end + + defp maybe_consume_stream({:error, _error} = error, :stream), do: error + defp maybe_consume_stream(other, :stream), do: {:error, {:invalid_stream_result, result_kind(other)}} + + defp capture_client(context, scenario, path, opts) do + Client.new( + api_key: Keyword.get(opts, :api_key, context.api_key), + timeout: @timeout, + transport: CaptureTransport, + transport_opts: [ + capture_provider: Keyword.get(opts, :provider, :deepseek), + capture_scenario: scenario, + capture_path: path + ] + ) + end + + defp result_status({:ok, _result}, false), do: "ok" + defp result_status({:ok, _result}, true), do: "unexpected_success" + + defp result_status({:error, error}, expected_error?) do + prefix = if expected_error?, do: "expected_error", else: "failed" + "#{prefix}_#{safe_error_type(error)}" + end + + defp result_status(other, _expected_error?), do: "failed_#{result_kind(other)}" + + defp safe_error_type(%{type: type}) when is_atom(type), do: Atom.to_string(type) + defp safe_error_type(%{__struct__: module}), do: module |> Module.split() |> List.last() |> Macro.underscore() + defp safe_error_type(_error), do: "unknown_error" + + defp result_kind({tag, _value}) when is_atom(tag), do: Atom.to_string(tag) + defp result_kind(value) when is_atom(value), do: Atom.to_string(value) + defp result_kind(_value), do: "unexpected_result" + + defp chat_tool_replay_body({:ok, response}, prerequisite_body) when is_map(response) do + with [%{"message" => message} | _rest] when is_map(message) <- response["choices"], + [_call | _rest] = tool_calls <- message["tool_calls"], + true <- Enum.all?(tool_calls, &valid_chat_tool_call?/1) do + assistant = + message + |> Map.take(["role", "content", "reasoning_content", "tool_calls"]) + |> Map.put_new("role", "assistant") + |> Map.put("tool_calls", tool_calls) + + tool_results = + Enum.map(tool_calls, fn call -> + %{ + "role" => "tool", + "tool_call_id" => call["id"], + "name" => get_in(call, ["function", "name"]), + "content" => BeamWeaver.JSON.encode!(%{"temperature_c" => 22}) + } + end) + + {:ok, + prerequisite_body + |> Map.put("messages", prerequisite_body["messages"] ++ [assistant] ++ tool_results) + |> Map.put("tool_choice", "auto")} + else + _missing_tool_call -> {:error, "prerequisite_did_not_return_a_valid_tool_call"} + end + end + + defp chat_tool_replay_body(_result, _prerequisite_body), + do: {:error, "prerequisite_request_failed"} + + defp valid_chat_tool_call?(%{ + "id" => id, + "function" => %{"name" => name, "arguments" => arguments} + }) do + is_binary(id) and id != "" and is_binary(name) and name != "" and is_binary(arguments) + end + + defp valid_chat_tool_call?(_call), do: false + + defp record_scenario_failure(context, entries, scenario, reason) do + path = failure_path(context, scenario) + + write_json!(path, %{ + "provider" => "deepseek", + "scenario" => scenario, + "status" => "failed", + "reason" => reason + }) + + entry = capture_entry(scenario, "failed_#{reason}", path) + print_summary(entry) + [entry | entries] + end + + defp ensure_failure_record(context, scenario, capture_path, reason) do + if File.exists?(capture_path) do + capture_path + else + path = failure_path(context, scenario) + + write_json!(path, %{ + "provider" => "deepseek", + "scenario" => scenario, + "status" => "failed", + "reason" => reason + }) + + path + end + end + + defp capture_entry(scenario, status, path) do + %{"scenario" => scenario, "status" => status, "path" => path} + end + + defp successful_entry?(%{"status" => "ok"}), do: true + defp successful_entry?(%{"status" => "expected_error_" <> _type}), do: true + defp successful_entry?(_entry), do: false + + defp print_summary(entry) do + Mix.shell().info("#{entry["scenario"]}: #{entry["status"]} -> #{entry["path"]}") + end + + defp basic_chat_body(model) do + %{ + "model" => model, + "messages" => [%{"role" => "user", "content" => "Reply with exactly: pong"}], + "thinking" => %{"type" => "disabled"}, + "max_tokens" => 32 + } + end + + defp thinking_chat_body(model) do + %{ + "model" => model, + "messages" => [%{"role" => "user", "content" => "Think briefly, then answer: 17 * 19"}], + "thinking" => %{"type" => "enabled"}, + "reasoning_effort" => "low", + "max_tokens" => 96 + } + end + + defp streaming_chat_body(model) do + model + |> thinking_chat_body() + |> Map.merge(stream_fields()) + end + + defp json_chat_body(model) do + %{ + "model" => model, + "messages" => [ + %{ + "role" => "user", + "content" => ~s(Return JSON only, exactly matching this shape: {"answer":"pong"}. The answer must be pong.) + } + ], + "thinking" => %{"type" => "disabled"}, + "response_format" => %{"type" => "json_object"}, + "max_tokens" => 64 + } + end + + defp cache_chat_body(model) do + stable_prefix = String.duplicate("This is a stable DeepSeek cache validation prefix. ", 128) + + %{ + "model" => model, + "messages" => [ + %{"role" => "system", "content" => stable_prefix}, + %{"role" => "user", "content" => "Reply with exactly: cached pong"} + ], + "thinking" => %{"type" => "disabled"}, + "max_tokens" => 32 + } + end + + defp prefix_chat_body(model) do + %{ + "model" => model, + "messages" => [ + %{"role" => "user", "content" => "Complete the sentence with one word."}, + %{"role" => "assistant", "content" => "The sky is", "prefix" => true} + ], + "thinking" => %{"type" => "disabled"}, + "max_tokens" => 16 + } + end + + defp strict_tool_chat_body(model) do + %{ + "model" => model, + "messages" => [ + %{"role" => "user", "content" => "Call get_weather for Nicosia. Do not answer directly."} + ], + "thinking" => %{"type" => "disabled"}, + "tools" => [chat_weather_tool(true)], + "tool_choice" => %{"type" => "function", "function" => %{"name" => "get_weather"}}, + "max_tokens" => 64 + } + end + + defp thinking_tool_chat_body(model) do + %{ + "model" => model, + "messages" => [ + %{"role" => "user", "content" => "Call get_weather for Nicosia. Do not answer directly."} + ], + "thinking" => %{"type" => "enabled"}, + "reasoning_effort" => "low", + "tools" => [chat_weather_tool(false)], + "max_tokens" => 128 + } + end + + defp chat_weather_tool(strict?) do + function = %{ + "name" => "get_weather", + "description" => "Return the current weather for a city.", + "parameters" => weather_schema() + } + + function = if strict?, do: Map.put(function, "strict", true), else: function + %{"type" => "function", "function" => function} + end + + defp fim_body(model) do + %{ + "model" => model, + "prompt" => "def fibonacci(n):\n ", + "suffix" => "\n\nprint(fibonacci(10))", + "max_tokens" => 48, + "logprobs" => 2 + } + end + + defp responses_basic_body do + %{ + "model" => @flash, + "input" => "Reply with exactly: responses pong", + "max_output_tokens" => 48 + } + end + + defp responses_schema_body do + %{ + "model" => @flash, + "input" => "Return an answer field whose value is exactly pong.", + "text" => %{ + "format" => %{ + "type" => "json_schema", + "name" => "capture_answer", + "strict" => true, + "schema" => answer_schema() + } + }, + "max_output_tokens" => 64 + } + end + + defp responses_function_body do + %{ + "model" => @flash, + "input" => "Call get_weather for Nicosia. Do not answer directly.", + "tools" => [responses_weather_tool()], + "tool_choice" => "auto", + "max_output_tokens" => 96 + } + end + + defp responses_forced_function_body do + responses_function_body() + |> Map.put("reasoning", %{"effort" => "none"}) + |> Map.put("tool_choice", %{"type" => "function", "name" => "get_weather"}) + end + + defp responses_web_search_body do + %{ + "model" => @flash, + "input" => "Search the web for the current DeepSeek API model names and summarize briefly.", + "tools" => [%{"type" => "web_search"}], + "tool_choice" => %{"type" => "web_search"}, + "max_output_tokens" => 2_048 + } + end + + defp responses_apply_patch_body do + %{ + "model" => @flash, + "input" => "Use apply_patch to add a single line containing hello to capture.txt.", + "tools" => [ + %{ + "type" => "custom", + "name" => "apply_patch", + "format" => %{ + "type" => "grammar", + "syntax" => "lark", + "definition" => "start: /[\\s\\S]+/" + } + } + ], + "tool_choice" => "auto", + "max_output_tokens" => 128 + } + end + + defp responses_weather_tool do + %{ + "type" => "function", + "name" => "get_weather", + "description" => "Return the current weather for a city.", + "parameters" => weather_schema(), + "strict" => true + } + end + + defp anthropic_basic_body(model) do + %{ + "model" => model, + "max_tokens" => 48, + "messages" => [ + %{ + "role" => "user", + "content" => [%{"type" => "text", "text" => "Reply with exactly: anthropic pong"}] + } + ] + } + end + + defp anthropic_thinking_body(model) do + %{ + "model" => model, + "max_tokens" => 1_024, + "thinking" => %{"type" => "enabled", "budget_tokens" => 512}, + "output_config" => %{"effort" => "low"}, + "messages" => [ + %{"role" => "user", "content" => "Think briefly, then answer: 23 * 29"} + ] + } + end + + defp anthropic_tool_body(model) do + %{ + "model" => model, + "max_tokens" => 128, + "messages" => [ + %{"role" => "user", "content" => "Call get_weather for Nicosia. Do not answer directly."} + ], + "thinking" => %{"type" => "disabled"}, + "tools" => [ + %{ + "name" => "get_weather", + "description" => "Return the current weather for a city.", + "input_schema" => weather_schema() + } + ], + "tool_choice" => %{"type" => "tool", "name" => "get_weather"} + } + end + + defp weather_schema do + %{ + "type" => "object", + "additionalProperties" => false, + "properties" => %{"city" => %{"type" => "string"}}, + "required" => ["city"] + } + end + + defp answer_schema do + %{ + "type" => "object", + "additionalProperties" => false, + "properties" => %{"answer" => %{"type" => "string"}}, + "required" => ["answer"] + } + end + + defp stream_fields do + %{"stream" => true, "stream_options" => %{"include_usage" => true}} + end + + defp model_slug(@flash), do: "flash" + defp model_slug(@pro), do: "pro" + + defp require_capture_guard do + if System.get_env(@capture_guard) == "true", + do: :ok, + else: {:error, :capture_disabled} + end + + defp required_api_key do + case System.get_env(@api_key_env) do + value when is_binary(value) and value != "" -> {:ok, value} + _missing -> {:error, :missing_api_key} + end + end + + defp output_dir! do + case System.get_env(@capture_dir_env) do + value when is_binary(value) and value != "" -> + Path.expand(value) + + _missing -> + fresh_temp_output_dir!() + end + end + + defp fresh_temp_output_dir! do + suffix = + 12 + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + + path = Path.join(System.tmp_dir!(), "beam_weaver-deepseek-live-#{suffix}") + + case File.mkdir(path) do + :ok -> path + {:error, :eexist} -> fresh_temp_output_dir!() + {:error, reason} -> raise File.Error, reason: reason, action: "create directory", path: path + end + end + + defp capture_path(context, scenario), do: Path.join(context.output_dir, "#{scenario}.json") + defp failure_path(context, scenario), do: Path.join(context.output_dir, "#{scenario}.failure.json") + + defp write_manifest!(context, entries) do + path = Path.join(context.output_dir, "manifest.json") + + write_json!(path, %{ + "captured_at" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(), + "timeout_ms" => @timeout, + "scenario_count" => length(entries), + "scenarios" => entries + }) + + path + end + + defp write_json!(path, value) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, BeamWeaver.JSON.encode!(value, pretty: true) <> "\n") + end +end + +BeamWeaver.DeepSeekLiveCapture.run() diff --git a/scripts/capture_provider_conformance.exs b/scripts/capture_provider_conformance.exs index 4d127f3..024c866 100644 --- a/scripts/capture_provider_conformance.exs +++ b/scripts/capture_provider_conformance.exs @@ -12,7 +12,7 @@ alias BeamWeaver.TestSupport.ProviderConformance, as: Fixtures defmodule BeamWeaver.ProviderConformanceCapture do @moduledoc false - @providers [:openai, :xai, :google, :moonshot, :zai] + @providers [:openai, :xai, :google, :moonshot, :zai, :deepseek, :deepseek_responses] def run do unless System.get_env("BEAM_WEAVER_CAPTURE_PROVIDER_FIXTURES") == "true" do @@ -27,6 +27,7 @@ defmodule BeamWeaver.ProviderConformanceCapture do GOOGLE_API_KEY=... \\ KIMI_API_KEY=... \\ ZAI_API_KEY=... \\ + DEEPSEEK_API_KEY=... \\ mix run scripts/capture_provider_conformance.exs """) @@ -49,7 +50,7 @@ defmodule BeamWeaver.ProviderConformanceCapture do defp capture_case(provider, api_key, {scenario, fun}) do scenario_name = Atom.to_string(scenario) - model = Fixtures.capture_model(provider, scenario_name, api_key: api_key) + model = Fixtures.capture_model(provider, scenario_name, api_key: api_key, timeout: 120_000) result = fun.(model) path = Fixtures.fixture_path(provider, scenario_name) @@ -105,6 +106,26 @@ defmodule BeamWeaver.ProviderConformanceCapture do ] end + defp provider_cases(:deepseek) do + [ + basic_chat: &basic_chat/1, + single_tool_call: &single_tool_call/1, + provider_structured_success: &provider_structured_success/1, + streaming_usage: &streaming_usage/1, + reasoning_stream_truncated: &reasoning_stream_truncated/1, + streaming_tool_call: &deepseek_streaming_tool_call/1 + ] + end + + defp provider_cases(:deepseek_responses) do + [ + basic_chat: &basic_chat/1, + single_tool_call: &single_tool_call/1, + provider_structured_success: &provider_structured_success/1, + streaming_usage: &streaming_usage/1 + ] + end + defp basic_chat(model) do ChatModel.invoke(model, [Message.user("Reply with exactly: pong")]) end @@ -190,19 +211,30 @@ defmodule BeamWeaver.ProviderConformanceCapture do ) end + defp deepseek_streaming_tool_call(model) do + model.__struct__.stream_response( + model, + [Message.user("Call get_weather for Tokyo. Do not answer directly.")], + tools: [Fixtures.weather_tool()], + tool_choice: "auto", + tool_stream: true, + thinking: %{type: "disabled"} + ) + end + defp expected_snapshot({:ok, %Message{} = message}) do %{"message" => Fixtures.message_snapshot(message)} end - defp expected_snapshot({:error, error}) do - %{"error" => Fixtures.error_snapshot(error)} - end + defp expected_snapshot({:error, _error}), + do: raise("provider conformance scenario returned an unexpected error") defp api_key(:openai), do: env_key("OPENAI_API_KEY") defp api_key(:xai), do: env_key("XAI_API_KEY") defp api_key(:google), do: env_key("GOOGLE_API_KEY") defp api_key(:moonshot), do: env_key(["KIMI_API_KEY", "MOONSHOT_API_KEY"]) defp api_key(:zai), do: env_key("ZAI_API_KEY") + defp api_key(provider) when provider in [:deepseek, :deepseek_responses], do: env_key("DEEPSEEK_API_KEY") defp env_key(names) when is_list(names) do Enum.find_value(names, :missing, fn name -> diff --git a/support/deep_agents/evals.exs b/support/deep_agents/evals.exs index bd07ea3..2803ba7 100644 --- a/support/deep_agents/evals.exs +++ b/support/deep_agents/evals.exs @@ -46,7 +46,7 @@ defmodule BeamWeaver.TestSupport.DeepAgents.Evals do "unit_test" => 9 } - @supported_providers ~w(fake openai anthropic google xai zai) + @supported_providers ~w(fake openai anthropic google xai zai deepseek) @provider_env %{ "openai" => "OPENAI_API_KEY", @@ -54,7 +54,8 @@ defmodule BeamWeaver.TestSupport.DeepAgents.Evals do "google" => "GOOGLE_API_KEY", "xai" => "XAI_API_KEY", "moonshot" => "MOONSHOT_API_KEY", - "zai" => "ZAI_API_KEY" + "zai" => "ZAI_API_KEY", + "deepseek" => "DEEPSEEK_API_KEY" } @provider_config_key %{ @@ -62,7 +63,8 @@ defmodule BeamWeaver.TestSupport.DeepAgents.Evals do "anthropic" => :anthropic, "google" => :google, "xai" => :xai, - "zai" => :zai + "zai" => :zai, + "deepseek" => :deepseek } @model_groups %{ @@ -71,20 +73,23 @@ defmodule BeamWeaver.TestSupport.DeepAgents.Evals do "openai:gpt-4.1", "anthropic:claude-sonnet-4-6", "google:gemini-3.5-flash", - "xai:grok-4.5" + "xai:grok-4.5", + "deepseek:deepseek-v4-flash" ], "fake" => ["fake"], "fast" => [ "anthropic:claude-sonnet-4-6", "google:gemini-3.5-flash", "openai:gpt-5.6-luna", - "openai:gpt-5.4-mini" + "openai:gpt-5.4-mini", + "deepseek:deepseek-v4-flash" ], "frontier" => [ "anthropic:claude-fable-5", "openai:gpt-5.6-sol", "openai:gpt-5.5", - "openai:gpt-5.5-pro" + "openai:gpt-5.5-pro", + "deepseek:deepseek-v4-pro" ], "openai" => [ "openai:gpt-4.1", @@ -114,6 +119,7 @@ defmodule BeamWeaver.TestSupport.DeepAgents.Evals do "google" => ["google:gemini-3.5-flash", "google:gemini-3.1-pro-preview"], "xai" => ["xai:grok-4.5", "xai:grok-4.3", "xai:grok-4.20-0309-non-reasoning"], "zai" => ["zai:glm-5.2"], + "deepseek" => ["deepseek:deepseek-v4-flash", "deepseek:deepseek-v4-pro"], "unsupported_cataloged" => [ "baseten:MiniMaxAI/MiniMax-M2.5", "fireworks:accounts/fireworks/models/deepseek-v3-0324", diff --git a/support/provider_conformance.exs b/support/provider_conformance.exs index 7b3d572..e1bb9f0 100644 --- a/support/provider_conformance.exs +++ b/support/provider_conformance.exs @@ -16,11 +16,17 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do @redacted_header_names MapSet.new([ "cf-ray", + "content-length", "date", "openai-organization", "openai-project", "request-id", "server", + "via", + "x-amz-cf-id", + "x-amz-cf-pop", + "x-cache", + "x-ds-trace-id", "x-request-id", "x-ratelimit-limit-requests", "x-ratelimit-limit-tokens", @@ -35,7 +41,9 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do "api_key", "apikey", "authorization", + "balance", "credential", + "currency", "created", "created_at", "org_id", @@ -43,9 +51,28 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do "password", "request_id", "secret", - "timestamp" + "timestamp", + "is_available" ] + @dynamic_id_keys MapSet.new([ + "call_id", + "id", + "item_id", + "provider_id", + "request_id", + "response_id", + "system_fingerprint", + "tool_call_id" + ]) + + @dynamic_timestamp_keys MapSet.new([ + "completed_at", + "created", + "created_at", + "timestamp" + ]) + @provider_modules %{ openai: BeamWeaver.OpenAI.ChatModel, openai_chat_completions: BeamWeaver.OpenAI.ChatCompletionsModel, @@ -53,7 +80,9 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do xai_chat_completions: BeamWeaver.XAI.ChatCompletionsModel, google: BeamWeaver.Google.ChatModel, moonshot: BeamWeaver.Moonshot.ChatModel, - zai: BeamWeaver.ZAI.ChatModel + zai: BeamWeaver.ZAI.ChatModel, + deepseek: BeamWeaver.DeepSeek.ChatModel, + deepseek_responses: BeamWeaver.DeepSeek.ResponsesModel } @provider_models %{ @@ -63,7 +92,9 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do xai_chat_completions: "grok-4.3", google: "gemini-3.5-flash", moonshot: "kimi-k2.6", - zai: "glm-5.2" + zai: "glm-5.2", + deepseek: "deepseek-v4-flash", + deepseek_responses: "deepseek-v4-flash" } @provider_api_keys %{ @@ -73,7 +104,9 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do xai_chat_completions: "xai-provider-conformance", google: "google-provider-conformance", moonshot: "moonshot-provider-conformance", - zai: "zai-provider-conformance" + zai: "zai-provider-conformance", + deepseek: "deepseek-provider-conformance", + deepseek_responses: "deepseek-provider-conformance" } def fixture_root, do: @fixture_root @@ -269,6 +302,7 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do path |> load_path!() |> Map.put("expected", normalize_for_fixture(expected)) + |> canonicalize_capture_fixture() write_json!(path, fixture) end @@ -294,7 +328,9 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do :xai_chat_completions, :google, :moonshot, - :zai + :zai, + :deepseek, + :deepseek_responses ], do: provider @@ -305,10 +341,12 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do def provider_atom!("google"), do: :google def provider_atom!("moonshot"), do: :moonshot def provider_atom!("zai"), do: :zai + def provider_atom!("deepseek"), do: :deepseek + def provider_atom!("deepseek_responses"), do: :deepseek_responses def provider_atom!(other) do raise ArgumentError, - "unsupported provider #{inspect(other)}; expected :openai, :openai_chat_completions, :xai, :xai_chat_completions, :google, :moonshot, or :zai" + "unsupported provider #{inspect(other)}; expected :openai, :openai_chat_completions, :xai, :xai_chat_completions, :google, :moonshot, :zai, :deepseek, or :deepseek_responses" end def provider_name!(provider), do: provider_atom!(provider) |> Atom.to_string() @@ -402,6 +440,91 @@ defmodule BeamWeaver.TestSupport.ProviderConformance do defp normalize_key(key) when is_atom(key), do: Atom.to_string(key) defp normalize_key(key), do: to_string(key) + defp canonicalize_capture_fixture(fixture) do + state = %{ids: %{}, next_id: 1} + {expected, state} = canonicalize_dynamic_values(fixture["expected"], state) + {response, _state} = canonicalize_dynamic_values(fixture["response"], state) + + fixture + |> Map.put("expected", expected) + |> Map.put("response", response) + end + + defp canonicalize_dynamic_values(map, state) when is_map(map) do + map + |> Enum.sort_by(fn {key, _value} -> normalize_key(key) end) + |> Enum.reduce({%{}, state}, fn {key, value}, {acc, state} -> + normalized_key = normalize_key(key) + + {value, state} = + cond do + MapSet.member?(@dynamic_timestamp_keys, normalized_key) and + value not in [nil, @redacted] -> + {@redacted, state} + + MapSet.member?(@dynamic_id_keys, normalized_key) and + is_binary(value) and value not in ["", @redacted] -> + canonicalize_id(value, state) + + true -> + canonicalize_dynamic_values(value, state) + end + + {Map.put(acc, key, value), state} + end) + end + + defp canonicalize_dynamic_values(values, state) when is_list(values) do + Enum.map_reduce(values, state, &canonicalize_dynamic_values/2) + end + + defp canonicalize_dynamic_values(value, state) when is_binary(value) do + canonicalize_sse(value, state) + end + + defp canonicalize_dynamic_values(value, state), do: {value, state} + + defp canonicalize_id(value, %{ids: ids} = state) do + case ids do + %{^value => canonical} -> + {canonical, state} + + _missing -> + canonical = "fixture-id-#{state.next_id}" + + {canonical, + %{ + state + | ids: Map.put(ids, value, canonical), + next_id: state.next_id + 1 + }} + end + end + + defp canonicalize_sse(value, state) do + if String.starts_with?(value, "data:") or String.contains?(value, "\ndata:") do + value + |> String.split("\n", trim: false) + |> Enum.map_reduce(state, &canonicalize_sse_line/2) + |> then(fn {lines, state} -> {Enum.join(lines, "\n"), state} end) + else + {value, state} + end + end + + defp canonicalize_sse_line("data: " <> data = line, state) do + case BeamWeaver.JSON.decode(data) do + {:ok, decoded} -> + {decoded, state} = canonicalize_dynamic_values(decoded, state) + {"data: " <> BeamWeaver.JSON.encode!(decoded), state} + + {:error, _error} -> + {line, state} + end + end + + defp canonicalize_sse_line(line, state), do: {line, state} + defp redact_fixture_term(%{__struct__: _module} = struct), do: struct |> Map.from_struct() |> redact_fixture_term() defp redact_fixture_term(map) when is_map(map) do diff --git a/test/beam_weaver/adapters/ecto_postgres_test.exs b/test/beam_weaver/adapters/ecto_postgres_test.exs new file mode 100644 index 0000000..f724540 --- /dev/null +++ b/test/beam_weaver/adapters/ecto_postgres_test.exs @@ -0,0 +1,40 @@ +defmodule BeamWeaver.Adapters.EctoPostgresTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Adapters.EctoPostgres + alias BeamWeaver.Core.Error + + defmodule Repo do + def transact(fun) when is_function(fun, 0) do + send(self(), {__MODULE__, :transact}) + + case fun.() do + {:ok, _result} = result -> result + {:error, _reason} = error -> error + end + end + + def transaction(_fun) do + send(self(), {__MODULE__, :transaction}) + raise "deprecated transaction/1 was called" + end + end + + test "uses Repo.transact/1 and preserves successful result shapes" do + assert :ok = EctoPostgres.transaction(Repo, fn -> :ok end) + assert {:ok, :value} = EctoPostgres.transaction(Repo, fn -> {:ok, :value} end) + assert :value = EctoPostgres.transaction(Repo, fn -> :value end) + + assert_received {Repo, :transact} + refute_received {Repo, :transaction} + end + + test "preserves BeamWeaver errors and normalizes other rollback reasons" do + error = Error.new(:expected, "expected") + + assert {:error, ^error} = EctoPostgres.transaction(Repo, fn -> {:error, error} end) + + assert {:error, %Error{type: :ecto_postgres_error, details: %{error: ":db_down"}}} = + EctoPostgres.transaction(Repo, fn -> {:error, :db_down} end) + end +end diff --git a/test/beam_weaver/deepseek/client_test.exs b/test/beam_weaver/deepseek/client_test.exs new file mode 100644 index 0000000..920acc7 --- /dev/null +++ b/test/beam_weaver/deepseek/client_test.exs @@ -0,0 +1,473 @@ +defmodule BeamWeaver.DeepSeek.ClientTest do + use ExUnit.Case, async: true + + alias BeamWeaver.DeepSeek.Client + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.Transport.Request + alias BeamWeaver.Transport.Response + + defmodule CaptureTransport do + @behaviour BeamWeaver.Transport + + @impl true + def request(%Request{} = request, opts) do + notify(opts, {:request, request, opts}) + {:ok, response(opts)} + end + + @impl true + def stream_reduce(%Request{} = request, opts, acc, reducer) do + notify(opts, {:stream_request, request, opts}) + response = response(opts) + + if response.status in 200..299 do + chunks = Keyword.get(opts, :chunks, [response.body]) + acc = Enum.reduce(chunks, acc, fn chunk, current -> reducer.(current, chunk) end) + {:ok, %{response | body: ""}, acc} + else + {:ok, response, acc} + end + end + + defp response(opts) do + %Response{ + status: Keyword.get(opts, :status, 200), + headers: Keyword.get(opts, :response_headers, []), + body: Keyword.get(opts, :response_body, ~s({"ok":true})), + metadata: %{} + } + end + + defp notify(opts, message) do + if parent = Keyword.get(opts, :parent), do: send(parent, message) + end + end + + test "derives every endpoint without duplicating API namespace suffixes" do + v1_client = Client.new(base_url: "https://deepseek.test/v1/") + + assert v1_client.base_url == "https://deepseek.test/v1" + assert v1_client.chat_completions_endpoint == "https://deepseek.test/v1/chat/completions" + assert v1_client.responses_endpoint == "https://deepseek.test/v1/responses" + assert v1_client.models_endpoint == "https://deepseek.test/v1/models" + assert v1_client.balance_endpoint == "https://deepseek.test/v1/user/balance" + assert v1_client.beta_base_url == "https://deepseek.test/beta" + assert v1_client.beta_chat_completions_endpoint == "https://deepseek.test/beta/chat/completions" + assert v1_client.completions_endpoint == "https://deepseek.test/beta/completions" + assert v1_client.anthropic_base_url == "https://deepseek.test/anthropic" + + assert v1_client.anthropic_messages_endpoint == + "https://deepseek.test/anthropic/v1/messages" + + for suffix <- ["beta", "anthropic"] do + client = Client.new(base_url: "https://deepseek.test/#{suffix}/") + + assert client.base_url == "https://deepseek.test" + assert client.chat_completions_endpoint == "https://deepseek.test/chat/completions" + assert client.responses_endpoint == "https://deepseek.test/responses" + assert client.models_endpoint == "https://deepseek.test/models" + assert client.balance_endpoint == "https://deepseek.test/user/balance" + assert client.beta_base_url == "https://deepseek.test/beta" + assert client.completions_endpoint == "https://deepseek.test/beta/completions" + assert client.anthropic_base_url == "https://deepseek.test/anthropic" + + assert client.anthropic_messages_endpoint == + "https://deepseek.test/anthropic/v1/messages" + end + + assert Client.endpoint("https://deepseek.test/beta/", "/completions") == + "https://deepseek.test/beta/completions" + end + + test "a configured chat endpoint remains authoritative for beta-capable bodies" do + client = Client.new(endpoint: "https://gateway.test/deepseek/chat") + + assert client.chat_completions_endpoint == "https://gateway.test/deepseek/chat" + assert client.beta_chat_completions_endpoint == "https://gateway.test/deepseek/chat" + end + + test "stable chat uses bearer auth, a 15 second default timeout, and response headers" do + client = + test_client( + response_headers: [ + {"X-DS-Trace-ID", "trace-chat"}, + {"Content-Type", "application/json"}, + {"Set-Cookie", "first=1"}, + {"Set-Cookie", "second=2"} + ], + response_body: ~s({"id":"chat-1","choices":[]}) + ) + + body = %{"model" => "deepseek-v4-flash", "messages" => []} + + assert {:ok, response} = Client.chat_completions(client, body, include_response_headers: true) + assert response["id"] == "chat-1" + + assert response["_beamweaver_response_header_metadata"] == %{ + headers: %{x_ds_trace_id: "trace-chat"}, + request_id: "trace-chat" + } + + assert response["_beamweaver_response_headers"] == %{ + "Content-Type" => "application/json", + "Set-Cookie" => "second=2", + "X-DS-Trace-ID" => "trace-chat" + } + + assert response["_beamweaver_response_header_list"] == [ + ["X-DS-Trace-ID", "trace-chat"], + ["Content-Type", "application/json"], + ["Set-Cookie", "first=1"], + ["Set-Cookie", "second=2"] + ] + + assert_received {:request, request, transport_opts} + assert request.method == :post + assert request.url == "https://deepseek.test/chat/completions" + assert request.json == body + assert request.options[:timeout] == 15_000 + assert transport_opts[:timeout] == 15_000 + assert {"authorization", "Bearer ds-secret"} in request.headers + assert {"user-agent", "beam_weaver-deepseek/0.1"} in request.headers + end + + test "prefix and strict tools automatically use beta chat while explicit endpoints win" do + client = test_client(response_body: ~s({"choices":[]})) + + prefix_body = %{ + "model" => "deepseek-v4-flash", + "messages" => [%{"role" => "assistant", "prefix" => true, "content" => "begin"}] + } + + assert {:ok, _response} = Client.chat_completions(client, prefix_body) + assert_received {:request, %{url: "https://deepseek.test/beta/chat/completions"}, _opts} + + strict_body = %{ + model: "deepseek-v4-flash", + tools: [%{type: "function", function: %{name: "lookup", strict: true}}] + } + + assert {:ok, _response} = Client.chat_completions(client, strict_body) + assert_received {:request, %{url: "https://deepseek.test/beta/chat/completions"}, _opts} + + assert {:ok, _response} = + Client.chat_completions(client, prefix_body, endpoint: "https://gateway.test/explicit-chat") + + assert_received {:request, %{url: "https://gateway.test/explicit-chat"}, _opts} + end + + test "responses, FIM, models, balance, and Anthropic messages use their exact routes" do + client = test_client(response_body: ~s({"ok":true})) + + assert {:ok, %{"ok" => true}} = Client.responses(client, %{"model" => "deepseek-v4-flash"}) + assert_received {:request, %{method: :post, url: "https://deepseek.test/responses"}, _opts} + + assert {:ok, %{"ok" => true}} = Client.completions(client, %{"prompt" => "def fib"}) + assert_received {:request, %{method: :post, url: "https://deepseek.test/beta/completions"}, _opts} + + assert {:ok, %{"ok" => true}} = Client.models(client) + assert_received {:request, %{method: :get, url: "https://deepseek.test/models", json: nil}, _opts} + + assert {:ok, %{"ok" => true}} = Client.balance(client) + + assert_received {:request, %{method: :get, url: "https://deepseek.test/user/balance", json: nil}, _opts} + + assert {:ok, %{"ok" => true}} = + Client.anthropic_messages(client, %{ + "model" => "claude-sonnet-4-5", + "messages" => [], + "max_tokens" => 16 + }) + + assert_received {:request, + %{ + method: :post, + url: "https://deepseek.test/anthropic/v1/messages", + headers: headers + }, _opts} + + assert {"x-api-key", "ds-secret"} in headers + assert {"anthropic-version", "2023-06-01"} in headers + refute Enum.any?(headers, fn {name, _value} -> name == "authorization" end) + end + + test "explicit per-surface endpoints and timeout are honored" do + client = + Client.new( + api_key: "ds-secret", + endpoint: "https://gateway.test/chat", + responses_endpoint: "https://gateway.test/responses", + completions_endpoint: "https://gateway.test/fim", + models_endpoint: "https://gateway.test/models", + balance_endpoint: "https://gateway.test/balance", + anthropic_messages_endpoint: "https://gateway.test/messages", + timeout: 42_000, + transport: CaptureTransport, + transport_opts: [parent: self(), response_body: ~s({"ok":true})] + ) + + requests = [ + {fn -> Client.chat_completions(client, %{}) end, :post, "https://gateway.test/chat"}, + {fn -> Client.responses(client, %{}) end, :post, "https://gateway.test/responses"}, + {fn -> Client.completions(client, %{}) end, :post, "https://gateway.test/fim"}, + {fn -> Client.models(client) end, :get, "https://gateway.test/models"}, + {fn -> Client.balance(client) end, :get, "https://gateway.test/balance"}, + {fn -> Client.anthropic_messages(client, %{}) end, :post, "https://gateway.test/messages"} + ] + + for {request, method, url} <- requests do + assert {:ok, %{"ok" => true}} = request.() + + assert_received {:request, %Request{method: ^method, url: ^url, options: request_options}, transport_opts} + + assert request_options[:timeout] == 42_000 + assert transport_opts[:timeout] == 42_000 + end + end + + test "chat streaming is lazy and collected responses retain reasoning, usage, and headers" do + sse = """ + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"think "},"finish_reason":null}]} + + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":"more"},"finish_reason":null}]} + + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]} + + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":1,"delta":{"role":"assistant","reasoning_content":"other "},"finish_reason":null}]} + + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":1,"delta":{"reasoning_content":"thought","content":"second"},"finish_reason":null}]} + + data: {"id":"chat-stream","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"insufficient_system_resource"},{"index":1,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}} + + data: [DONE] + + """ + + client = + test_client( + response_headers: [{"x-ds-trace-id", "trace-stream"}], + response_body: sse, + chunks: chunk_every(sse, 17) + ) + + body = %{"model" => "deepseek-v4-pro", "messages" => [], "stream" => true} + assert {:ok, stream} = Client.chat_completions_stream(client, body) + refute_received {:stream_request, _request, _opts} + assert Enum.to_list(stream) == ["answer", "second"] + assert_received {:stream_request, %{url: "https://deepseek.test/chat/completions"}, _opts} + + assert {:ok, typed_stream} = Client.chat_completions_stream_typed_events(client, body) + typed_events = Enum.to_list(typed_stream) + + assert Enum.any?(typed_events, fn envelope -> + envelope.metadata == %{provider: :deepseek, block_type: :reasoning} + end) + + assert {:ok, response} = + Client.chat_completions_stream_response(client, body, include_response_headers: true) + + assert get_in(response, ["choices", Access.at(0), "message", "content"]) == "answer" + + assert get_in(response, ["choices", Access.at(0), "message", "reasoning_content"]) == + "think more" + + assert get_in(response, ["choices", Access.at(1), "message", "content"]) == "second" + + assert get_in(response, ["choices", Access.at(1), "message", "reasoning_content"]) == + "other thought" + + assert get_in(response, ["choices", Access.at(1), "finish_reason"]) == "stop" + + assert get_in(response, ["choices", Access.at(0), "finish_reason"]) == + "insufficient_system_resource" + + assert response["usage"] == %{ + "prompt_tokens" => 3, + "completion_tokens" => 2, + "total_tokens" => 5 + } + + assert response["_beamweaver_response_header_metadata"].request_id == "trace-stream" + assert response["_beamweaver_response_headers"] == %{"x-ds-trace-id" => "trace-stream"} + assert response["_beamweaver_response_header_list"] == [["x-ds-trace-id", "trace-stream"]] + end + + test "Responses, FIM, and Anthropic collected streams reuse their wire reconstructors" do + responses_sse = """ + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg-1","type":"message","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg-1","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg-1","output_index":0,"content_index":0,"delta":"hello"} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp-1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}} + + """ + + client = test_client(response_body: responses_sse) + body = %{"model" => "deepseek-v4-flash", "input" => "hello", "stream" => true} + + assert {:ok, stream} = Client.responses_stream(client, body) + assert Enum.to_list(stream) == ["hello"] + assert {:ok, response} = Client.responses_stream_response(client, body) + assert response["id"] == "resp-1" + assert get_in(response, ["output", Access.at(0), "content", Access.at(0), "text"]) == "hello" + + fim_sse = """ + data: {"id":"cmpl-1","object":"text_completion","model":"deepseek-v4-flash","choices":[{"index":0,"text":"foo","finish_reason":null,"logprobs":{"tokens":["foo"],"token_logprobs":[-0.1],"top_logprobs":[{"foo":-0.1}],"text_offset":[0]}}]} + + data: {"id":"cmpl-1","object":"text_completion","model":"deepseek-v4-flash","choices":[{"index":0,"text":"bar","finish_reason":"stop","logprobs":{"tokens":["bar"],"token_logprobs":[-0.2],"top_logprobs":[{"bar":-0.2}],"text_offset":[3]}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}} + + data: [DONE] + + """ + + client = test_client(response_body: fim_sse, chunks: chunk_every(fim_sse, 11)) + assert {:ok, stream} = Client.completions_stream(client, %{"prompt" => "f", "stream" => true}) + assert Enum.to_list(stream) == ["foo", "bar"] + + assert {:ok, fim_response} = + Client.completions_stream_response(client, %{"prompt" => "f", "stream" => true}) + + assert get_in(fim_response, ["choices", Access.at(0), "text"]) == "foobar" + assert get_in(fim_response, ["choices", Access.at(0), "finish_reason"]) == "stop" + + assert get_in(fim_response, ["choices", Access.at(0), "logprobs"]) == %{ + "tokens" => ["foo", "bar"], + "token_logprobs" => [-0.1, -0.2], + "top_logprobs" => [%{"foo" => -0.1}, %{"bar" => -0.2}], + "text_offset" => [0, 3] + } + + assert fim_response["usage"]["total_tokens"] == 3 + + anthropic_sse = """ + event: message_start + data: {"type":"message_start","message":{"id":"msg-1","type":"message","role":"assistant","model":"deepseek-v4-flash","content":[],"usage":{"input_tokens":2,"output_tokens":0}}} + + event: content_block_start + data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + + event: content_block_delta + data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + + event: message_delta + data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}} + + """ + + client = test_client(response_body: anthropic_sse) + anthropic_body = %{"model" => "claude-sonnet-4-5", "messages" => [], "stream" => true} + assert {:ok, stream} = Client.anthropic_messages_stream(client, anthropic_body) + assert Enum.to_list(stream) == ["hi"] + + assert {:ok, anthropic_response} = + Client.anthropic_messages_stream_response(client, anthropic_body) + + assert anthropic_response["id"] == "msg-1" + assert get_in(anthropic_response, ["content", Access.at(0), "text"]) == "hi" + assert anthropic_response["stop_reason"] == "end_turn" + end + + test "normalizes DeepSeek error status, JSON octet-stream bodies, trace IDs, and retryability" do + cases = [ + {401, "authentication_error", :authentication_error, false}, + {402, "insufficient balance", :quota_error, false}, + {429, "rate limit reached", :rate_limit_error, true}, + {503, "server overloaded", :overloaded_error, true}, + {422, "invalid parameters", :invalid_request_error, false}, + {500, "internal server error", :server_error, true} + ] + + for {status, message, type, retryable} <- cases do + client = + test_client( + status: status, + response_headers: [ + {"Content-Type", "application/octet-stream"}, + {"X-DS-Trace-ID", "trace-#{status}"} + ], + response_body: + BeamWeaver.JSON.encode!(%{ + "error" => %{"message" => message, "type" => "invalid_request_error"} + }) + ) + + assert {:error, %Error{} = error} = Client.chat_completions(client, %{}) + assert error.type == type + assert error.message == message + assert error.details.status == status + assert error.details.request_id == "trace-#{status}" + assert error.details.retryable == retryable + end + + context_client = + test_client( + status: 400, + response_body: + ~s({"error":{"message":"Input tokens exceed the maximum context length","code":"context_length_exceeded"}}) + ) + + assert {:error, %Error{type: :context_overflow}} = + Client.chat_completions(context_client, %{}) + end + + test "stream failures use the same DeepSeek error normalization" do + client = + test_client( + status: 429, + response_body: ~s({"error":{"message":"slow down","type":"rate_limit_error"}}) + ) + + assert {:ok, stream} = Client.responses_stream(client, %{"stream" => true}) + + assert [%BeamWeaver.Stream.Events.Error{error: %Error{type: :rate_limit_error}}] = + Enum.to_list(stream) + end + + test "client Inspect redacts eager and lazy API keys without resolving lazy secrets" do + parent = self() + + eager = Client.new(api_key: "ds-eager-secret") + + lazy = + Client.new( + api_key: fn -> + send(parent, :secret_resolved) + "ds-lazy-secret" + end + ) + + for client <- [eager, lazy] do + inspected = inspect(client, limit: :infinity) + refute inspected =~ "ds-eager-secret" + refute inspected =~ "ds-lazy-secret" + assert inspected =~ BeamWeaver.Transport.Redactor.redacted() + end + + refute_received :secret_resolved + end + + defp test_client(response_opts) do + Client.new( + base_url: "https://deepseek.test", + api_key: "ds-secret", + transport: CaptureTransport, + transport_opts: [parent: self()] ++ response_opts + ) + end + + defp chunk_every(body, size), do: do_chunk_every(body, size, []) + defp do_chunk_every("", _size, acc), do: Enum.reverse(acc) + + defp do_chunk_every(body, size, acc) do + case body do + <> -> do_chunk_every(rest, size, [chunk | acc]) + chunk -> Enum.reverse([chunk | acc]) + end + end +end diff --git a/test/beam_weaver/deepseek/contract_test.exs b/test/beam_weaver/deepseek/contract_test.exs new file mode 100644 index 0000000..34e1cee --- /dev/null +++ b/test/beam_weaver/deepseek/contract_test.exs @@ -0,0 +1,140 @@ +defmodule BeamWeaver.DeepSeek.ContractTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Core.ContentBlock + alias BeamWeaver.Core.Message + alias BeamWeaver.DeepSeek.ChatModel + alias BeamWeaver.DeepSeek.Messages + + @chat_tool %{ + "type" => "function", + "function" => %{ + "name" => "lookup", + "description" => "Look something up", + "parameters" => %{"type" => "object", "properties" => %{}} + } + } + + test "Chat rejects image, audio, video, and file content before transport" do + blocks = [ + {ContentBlock.image(%{url: "https://example.test/image.png"}), :image}, + {ContentBlock.audio(%{data: "audio", mime_type: "audio/mpeg"}), :audio}, + {ContentBlock.video(%{data: "video", mime_type: "video/mp4"}), :video}, + {ContentBlock.file(%{data: "file", mime_type: "application/pdf"}), :file} + ] + + for {block, feature} <- blocks do + assert {:error, error} = + ChatModel.request_body(ChatModel.new(), [Message.user([block])]) + + assert error.type == :unsupported_feature + assert error.details.feature == feature + assert error.details.api == :chat_completions + end + end + + test "Chat enforces stop, tool-count, function-name, user-id, and ignored-parameter limits" do + model = ChatModel.new() + messages = [Message.user("hello")] + + assert {:error, stop_error} = + ChatModel.request_body(model, messages, stop: List.duplicate("END", 17)) + + assert stop_error.details.param == :stop + + assert {:error, tools_error} = + ChatModel.request_body(model, messages, tools: List.duplicate(@chat_tool, 129)) + + assert tools_error.details.max == 128 + + invalid_name = put_in(@chat_tool, ["function", "name"], String.duplicate("a", 65)) + assert {:error, name_error} = ChatModel.request_body(model, messages, tools: [invalid_name]) + assert name_error.details.api == :chat_completions + + assert {:error, user_error} = ChatModel.request_body(model, messages, user_id: "invalid user") + assert user_error.details.param == :user_id + + for param <- [:frequency_penalty, :presence_penalty] do + assert {:error, error} = + ChatModel.request_body(model, messages, model_kwargs: %{param => 0.5}) + + assert error.type == :unsupported_model_param + assert param in error.details.params + end + end + + test "Chat preserves every documented and provider-specific finish reason" do + for finish_reason <- [ + "stop", + "length", + "tool_calls", + "content_filter", + "insufficient_system_resource", + "future_finish_reason" + ] do + response = %{ + "id" => "chat-finish", + "model" => "deepseek-v4-flash", + "choices" => [ + %{ + "finish_reason" => finish_reason, + "message" => %{"role" => "assistant", "content" => "done"} + } + ] + } + + assert {:ok, message} = Messages.chat_response_to_message(response) + assert message.status == finish_reason + assert message.metadata.finish_reason == finish_reason + end + end + + test "Responses accepts no assistant message and preserves failed and unknown output items" do + output = [ + %{ + "type" => "reasoning", + "id" => "reasoning-1", + "status" => "completed", + "content" => [%{"type" => "reasoning_text", "text" => "searched"}] + }, + %{ + "type" => "web_search_call", + "id" => "search-1", + "status" => "failed", + "action" => %{"type" => "search", "query" => "DeepSeek"} + }, + %{ + "type" => "future_deepseek_item", + "id" => "future-1", + "status" => "completed", + "payload" => %{"kept" => true} + } + ] + + response = %{ + "id" => "response-1", + "model" => "deepseek-v4-flash", + "status" => "incomplete", + "incomplete_details" => %{"reason" => "max_output_tokens"}, + "output" => output + } + + assert {:ok, message} = Messages.responses_to_message(response) + assert Message.text(message) == "" + assert message.status == "incomplete" + assert message.metadata.incomplete_details == %{"reason" => "max_output_tokens"} + assert message.metadata.output == output + assert message.metadata.raw_provider_response["output"] == output + + assert Enum.any?(message.content, fn + %ContentBlock.Unknown{ + provider_type: "web_search_call", + value: %{"status" => "failed", "action" => %{"query" => "DeepSeek"}} + } -> + true + + _block -> + false + end) + end +end diff --git a/test/beam_weaver/deepseek/model_test.exs b/test/beam_weaver/deepseek/model_test.exs new file mode 100644 index 0000000..a3c5f4f --- /dev/null +++ b/test/beam_weaver/deepseek/model_test.exs @@ -0,0 +1,775 @@ +defmodule BeamWeaver.DeepSeek.ModelTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Core.ContentBlock + alias BeamWeaver.Core.Message + alias BeamWeaver.DeepSeek.ChatModel + alias BeamWeaver.DeepSeek.Messages + alias BeamWeaver.DeepSeek.ResponsesModel + alias BeamWeaver.DeepSeek.Tools + + defmodule StaticTransport do + @behaviour BeamWeaver.Transport + + alias BeamWeaver.Transport.Request + alias BeamWeaver.Transport.Response + + @impl true + def request(%Request{} = request, opts) do + if parent = Keyword.get(opts, :parent), do: send(parent, {:request, request}) + + {:ok, + %Response{ + status: Keyword.get(opts, :status, 200), + headers: Keyword.get(opts, :response_headers, []), + body: Keyword.fetch!(opts, :response_body), + metadata: %{} + }} + end + + @impl true + def stream_reduce(%Request{} = request, opts, acc, reducer) do + with {:ok, response} <- request(request, opts) do + acc = reducer.(acc, response.body) + {:ok, %{response | body: ""}, acc} + end + end + end + + @function_tool %{ + "type" => "function", + "name" => "lookup", + "description" => "Look something up", + "parameters" => %{"type" => "object", "properties" => %{}} + } + + @chat_function_tool %{ + "type" => "function", + "function" => Map.delete(@function_tool, "type") + } + + test "Chat defaults to V4 Flash and preserves reasoning replay and prefix completion" do + model = ChatModel.new() + + assistant = + Message.assistant("partial", + metadata: %{reasoning_content: "private chain", prefix: true} + ) + + assert {:ok, body} = + ChatModel.request_body(model, [Message.user("question"), assistant], thinking: %{type: "disabled"}) + + assert body["model"] == "deepseek-v4-flash" + + assert List.last(body["messages"]) == %{ + "role" => "assistant", + "content" => "partial", + "reasoning_content" => "private chain", + "prefix" => true + } + end + + test "Chat validates documented numeric request bounds before transport" do + model = ChatModel.new() + messages = [Message.user("hello")] + + for {param, value} <- [ + {:max_tokens, 0}, + {:max_tokens, 393_217}, + {:max_tokens, 1.5}, + {:temperature, -0.1}, + {:temperature, 2.1}, + {:top_p, -0.1}, + {:top_p, 1.1} + ] do + assert {:error, error} = ChatModel.request_body(model, messages, [{param, value}]) + assert error.type == :invalid_request + assert error.details.param == param + end + + assert {:ok, _body} = + ChatModel.request_body(model, messages, + max_tokens: 393_216, + temperature: 2, + top_p: 1 + ) + end + + test "Chat requires conversation history and validates logprobs as a boolean" do + model = ChatModel.new() + + assert {:error, error} = ChatModel.request_body(model, []) + assert error.type == :invalid_messages + + assert {:error, error} = + ChatModel.request_body(model, [Message.user("hello")], logprobs: "true") + + assert error.type == :invalid_request + assert error.details.param == :logprobs + + assert {:ok, body} = + ChatModel.request_body(model, [Message.user("hello")], + logprobs: true, + top_logprobs: 20 + ) + + assert body["logprobs"] == true + assert body["top_logprobs"] == 20 + end + + test "Chat requires map stream_options and streaming mode" do + model = ChatModel.new() + messages = [Message.user("hello")] + + assert {:error, error} = + ChatModel.request_body(model, messages, stream: true, stream_options: :usage) + + assert error.type == :invalid_request + assert error.details.param == :stream_options + + assert {:error, error} = + ChatModel.request_body(model, messages, stream_options: %{include_usage: true}) + + assert error.type == :unsupported_model_param + + assert {:ok, body} = + ChatModel.request_body(model, messages, + stream: true, + stream_options: %{include_usage: true} + ) + + assert body["stream_options"] == %{"include_usage" => true} + end + + test "Chat explicit tool choice requires thinking explicitly disabled" do + model = ChatModel.new() + messages = [Message.user("hello")] + + opts = [ + tools: [@chat_function_tool], + tool_choice: %{type: "function", function: %{name: "lookup"}} + ] + + assert {:error, error} = ChatModel.request_body(model, messages, opts) + assert error.type == :unsupported_model_param + assert error.details.required == %{thinking: %{type: "disabled"}} + + assert {:ok, body} = + ChatModel.request_body( + model, + messages, + Keyword.put(opts, :thinking, %{type: "disabled"}) + ) + + assert body["tool_choice"] == %{ + "type" => "function", + "function" => %{"name" => "lookup"} + } + + for choice <- [:none, :auto, :required] do + assert {:error, error} = + ChatModel.request_body(model, messages, + tools: [@chat_function_tool], + tool_choice: choice + ) + + assert error.type == :unsupported_model_param + assert error.details.required == %{thinking: %{type: "disabled"}} + + assert {:ok, body} = + ChatModel.request_body(model, messages, + tools: [@chat_function_tool], + tool_choice: choice, + thinking: %{type: "disabled"} + ) + + assert body["tool_choice"] == Atom.to_string(choice) + end + end + + test "Chat rejects non-map model_kwargs from model and invocation options" do + messages = [Message.user("hello")] + + for {model, opts} <- [ + {ChatModel.new(model_kwargs: [:not, :a, :map]), []}, + {ChatModel.new(), [model_kwargs: "not-a-map"]} + ] do + assert {:error, error} = ChatModel.request_body(model, messages, opts) + assert error.type == :invalid_request + assert error.details.param == :model_kwargs + end + end + + test "Chat validates tools, response format, and tool choice from the final model_kwargs body" do + messages = [Message.user("hello")] + + assert {:error, error} = + ChatModel.request_body(ChatModel.new(model_kwargs: %{tools: "not-a-list"}), messages) + + assert error.type == :invalid_request + assert error.details.param == :tools + + strict = put_in(@chat_function_tool, ["function", "strict"], true) + loose = put_in(@chat_function_tool, ["function", "name"], "other") + + assert {:error, error} = + ChatModel.request_body( + ChatModel.new(model_kwargs: %{tools: [strict, loose]}), + messages + ) + + assert error.type == :invalid_request + assert error.details.feature == :strict_tools + + assert {:error, error} = + ChatModel.request_body( + ChatModel.new(model_kwargs: %{response_format: %{type: :json_schema}}), + messages + ) + + assert error.type == :invalid_response_format + + assert {:error, error} = + ChatModel.request_body( + ChatModel.new( + model_kwargs: %{ + thinking: %{type: :disabled}, + tools: [@chat_function_tool], + tool_choice: %{type: :function, function: %{name: "missing"}} + } + ), + messages + ) + + assert error.type == :invalid_request + assert error.details.name == "missing" + + assert {:ok, body} = + ChatModel.request_body( + ChatModel.new( + model_kwargs: %{ + thinking: %{type: :disabled}, + tools: [@chat_function_tool], + tool_choice: %{type: :function, function: %{name: "lookup"}}, + response_format: %{type: :json_object} + } + ), + messages + ) + + assert body["response_format"] == %{"type" => "json_object"} + assert body["tool_choice"] == %{"type" => "function", "function" => %{"name" => "lookup"}} + end + + test "Chat maps JSON Schema requests to documented JSON object mode with a schema instruction" do + schema = %{ + "type" => "object", + "properties" => %{"ok" => %{"type" => "boolean"}} + } + + assert {:ok, body} = + ChatModel.request_body(ChatModel.new(), [Message.user("hello")], + response_format: %{type: :json_schema, name: "answer", schema: schema} + ) + + assert body["response_format"] == %{"type" => "json_object"} + assert [%{"role" => "system", "content" => instruction}, _user] = body["messages"] + assert instruction =~ "Schema name: answer" + assert instruction =~ "Return exactly one JSON object" + end + + test "Chat model-level response_format and structured_output parse and validate responses" do + schema = %{ + "type" => "object", + "properties" => %{"ok" => %{"type" => "boolean"}}, + "required" => ["ok"] + } + + format = %{ + type: :json_schema, + name: "answer", + schema: schema, + validator: fn + %{"ok" => true} = parsed -> {:ok, Map.put(parsed, "validated", true)} + parsed -> {:error, {:unexpected_response, parsed}} + end + } + + response_body = + BeamWeaver.JSON.encode!(%{ + "id" => "chat_structured", + "model" => "deepseek-v4-flash", + "choices" => [ + %{ + "finish_reason" => "stop", + "message" => %{"role" => "assistant", "content" => ~s({"ok":true})} + } + ] + }) + + for option <- [:response_format, :structured_output] do + model = + ChatModel.new([ + {option, format}, + {:api_key, "test-key"}, + {:transport, StaticTransport}, + {:transport_opts, [response_body: response_body]} + ]) + + assert {:ok, message} = ChatModel.invoke(model, [Message.user("return JSON")]) + assert message.metadata.parsed == %{"ok" => true, "validated" => true} + end + + rejecting_model = + ChatModel.new( + structured_output: %{format | validator: fn _parsed -> {:error, :rejected} end}, + api_key: "test-key", + transport: StaticTransport, + transport_opts: [response_body: response_body] + ) + + assert {:error, error} = ChatModel.invoke(rejecting_model, [Message.user("return JSON")]) + assert error.type == :structured_output_parse_error + assert error.details.reason == ":rejected" + end + + test "Chat strict mode requires every declared function to be strict" do + strict = put_in(@chat_function_tool, ["function", "strict"], true) + loose = put_in(@chat_function_tool, ["function", "name"], "other") + + assert {:error, error} = + ChatModel.request_body(ChatModel.new(), [Message.user("hello")], tools: [strict, loose]) + + assert error.type == :invalid_request + assert error.details.feature == :strict_tools + + assert {:ok, _body} = + ChatModel.request_body(ChatModel.new(), [Message.user("hello")], + tools: [strict, put_in(loose, ["function", "strict"], true)] + ) + end + + test "non-stream Chat response exposes reasoning as a content block and visible text remains stable" do + response = %{ + "id" => "chat_1", + "model" => "deepseek-v4-flash", + "choices" => [ + %{ + "finish_reason" => "stop", + "message" => %{"content" => "visible answer", "reasoning_content" => "private chain"} + } + ], + "usage" => %{ + "prompt_tokens" => 10, + "prompt_cache_hit_tokens" => 4, + "prompt_cache_miss_tokens" => 6, + "completion_tokens" => 5, + "total_tokens" => 15 + } + } + + assert {:ok, message} = Messages.chat_response_to_message(response) + assert Message.text(message) == "visible answer" + + assert [%ContentBlock.Reasoning{reasoning: "private chain"}, %ContentBlock.Text{text: "visible answer"}] = + message.content + + assert message.metadata.reasoning_content == "private chain" + assert_in_delta message.usage_metadata.total_cost, 0.0000022512, 1.0e-12 + end + + test "Responses requires input or instructions and validates request bounds and user" do + model = ResponsesModel.new() + + assert {:error, error} = ResponsesModel.request_body(model, []) + assert error.type == :invalid_request + assert error.message =~ "nonempty input or instructions" + + assert {:ok, body} = ResponsesModel.request_body(model, [], instructions: "Say hello") + assert body["instructions"] == "Say hello" + + for {param, value} <- [ + {:max_output_tokens, 0}, + {:max_output_tokens, 393_217}, + {:max_output_tokens, 1.5}, + {:temperature, -0.1}, + {:temperature, 2.1}, + {:top_p, -0.1}, + {:top_p, 1.1}, + {:top_logprobs, -1}, + {:top_logprobs, 21}, + {:top_logprobs, 1.5}, + {:user, "spaces are invalid"} + ] do + assert {:error, error} = + ResponsesModel.request_body(model, [Message.user("hello")], [{param, value}]) + + assert error.type == :invalid_request + assert error.details.param == param + end + + assert {:ok, _body} = + ResponsesModel.request_body(model, [Message.user("hello")], + max_output_tokens: 393_216, + temperature: 2, + top_p: 1, + top_logprobs: 20, + user: "user_123" + ) + end + + test "Responses keeps model and stream authoritative across request escape hatches" do + model = ResponsesModel.new() + messages = [Message.user("hello")] + + for opts <- [ + [model: "deepseek-v4-pro"], + [model_kwargs: %{model: "deepseek-v4-pro"}], + [extra_body: %{model: "deepseek-v4-pro"}] + ] do + assert {:error, error} = ResponsesModel.request_body(model, messages, opts) + assert error.type == :unsupported_model + assert error.details.model == "deepseek-v4-pro" + end + + assert {:ok, body} = + ResponsesModel.request_body(model, messages, + model_kwargs: %{stream: true}, + extra_body: %{stream: true} + ) + + assert body["model"] == "deepseek-v4-flash" + assert body["stream"] == false + + assert {:ok, streaming_body} = + ResponsesModel.request_body(model, messages, + stream: true, + model_kwargs: %{stream: false}, + extra_body: %{stream: false} + ) + + assert streaming_body["stream"] == true + end + + test "Responses rejects malformed option containers as request errors" do + model = ResponsesModel.new() + messages = [Message.user("hello")] + + for {param, opts} <- [ + {:tools, [tools: :bad]}, + {:model_kwargs, [model_kwargs: :bad]}, + {:extra_body, [extra_body: :bad]}, + {:reasoning, [reasoning: :high]}, + {:text, [text: :json]}, + {:text, [model_kwargs: %{text: :json}]} + ] do + assert {:error, error} = ResponsesModel.request_body(model, messages, opts) + assert error.type == :invalid_request + assert error.details.param == param + end + end + + test "Responses rejects stateless fields by presence in escape hatches" do + model = ResponsesModel.new() + messages = [Message.user("hello")] + + for opts <- [ + [store: false], + [model_kwargs: %{store: false}], + [extra_body: %{store: false}], + [extra_body: %{store: nil}] + ] do + assert {:error, error} = ResponsesModel.request_body(model, messages, opts) + assert error.type == :unsupported_model_param + assert :store in error.details.params + assert error.details.stateless == true + end + end + + test "Responses validates instructions and reasoning effort" do + model = ResponsesModel.new() + messages = [Message.user("hello")] + + for instructions <- [123, ""] do + assert {:error, error} = + ResponsesModel.request_body(model, messages, instructions: instructions) + + assert error.type == :invalid_request + assert error.details.param == :instructions + end + + for effort <- [:none, :low, :medium, :high, :xhigh, :max] do + assert {:ok, body} = + ResponsesModel.request_body(model, messages, reasoning_effort: effort) + + assert body["reasoning"] == %{"effort" => Atom.to_string(effort)} + end + + assert {:error, error} = + ResponsesModel.request_body(model, messages, reasoning_effort: :bananas) + + assert error.type == :invalid_request + assert error.details.param == :reasoning + assert error.details.effort == "bananas" + end + + test "Responses honors model-level top_logprobs with per-call precedence" do + model = ResponsesModel.new(top_logprobs: 2) + messages = [Message.user("hello")] + + assert {:ok, body} = ResponsesModel.request_body(model, messages) + assert body["top_logprobs"] == 2 + + assert {:ok, overridden} = ResponsesModel.request_body(model, messages, top_logprobs: 4) + assert overridden["top_logprobs"] == 4 + end + + test "Responses rejects unknown and media input items" do + model = ResponsesModel.new() + + for item <- [ + %{"type" => "unknown_future_item"}, + %{"type" => "message", "role" => "user", "content" => [%{"type" => "input_image"}]}, + %{} + ] do + assert {:error, error} = ResponsesModel.request_body(model, [], input_items: [item]) + assert error.type == :unsupported_feature + end + end + + test "Responses requires unique call ids and exactly one function output per call" do + model = ResponsesModel.new() + call = %{"type" => "function_call", "call_id" => "call_1", "name" => "lookup", "arguments" => "{}"} + output = %{"type" => "function_call_output", "call_id" => "call_1", "output" => "ok"} + + assert {:error, error} = ResponsesModel.request_body(model, [], input_items: [call]) + assert error.message =~ "exactly one output" + + assert {:ok, body} = ResponsesModel.request_body(model, [], input_items: [call, output]) + assert body["input"] == [call, output] + + assert {:error, error} = + ResponsesModel.request_body(model, [], input_items: [call, call, output]) + + assert error.message =~ "unique" + + assert {:error, error} = + ResponsesModel.request_body(model, [], + input_items: [%{"type" => "function_call", "call_id" => "", "name" => "lookup"}] + ) + + assert error.message =~ "nonempty" + end + + test "Responses validates required function and custom call payload fields" do + model = ResponsesModel.new() + + valid_call = %{ + "type" => "function_call", + "call_id" => "call_1", + "name" => "lookup", + "arguments" => "{}" + } + + valid_output = %{ + "type" => "function_call_output", + "call_id" => "call_1", + "output" => "ok" + } + + for {param, call, output} <- [ + {:name, Map.delete(valid_call, "name"), valid_output}, + {:arguments, Map.delete(valid_call, "arguments"), valid_output}, + {:output, valid_call, Map.delete(valid_output, "output")} + ] do + assert {:error, error} = + ResponsesModel.request_body(model, [], input_items: [call, output]) + + assert error.type == :invalid_request + assert error.details.param == param + end + + tool = Tools.apply_patch() + + custom_call = %{ + "type" => "custom_tool_call", + "call_id" => "patch_1", + "name" => "apply_patch", + "input" => "*** Begin Patch" + } + + custom_output = %{ + "type" => "custom_tool_call_output", + "call_id" => "patch_1", + "output" => "Done!" + } + + for {param, call, output} <- [ + {:input, Map.delete(custom_call, "input"), custom_output}, + {:output, custom_call, Map.delete(custom_output, "output")} + ] do + assert {:error, error} = + ResponsesModel.request_body(model, [], + tools: [tool], + input_items: [call, output] + ) + + assert error.type == :invalid_request + assert error.details.param == param + end + end + + test "Responses replays DeepSeek's custom apply_patch extension and pairs outputs" do + model = ResponsesModel.new() + tool = Tools.apply_patch(format: %{type: "grammar", syntax: "lark", definition: "start: /.+/"}) + + call = %{ + "type" => "custom_tool_call", + "call_id" => "patch_1", + "name" => "apply_patch", + "input" => "*** Begin Patch" + } + + output = %{ + "type" => "custom_tool_call_output", + "call_id" => "patch_1", + "output" => "Done!" + } + + assert {:ok, body} = + ResponsesModel.request_body(model, [], + tools: [tool], + input_items: [call, output], + reasoning: %{effort: "none"}, + tool_choice: %{type: "custom", name: "apply_patch"} + ) + + assert body["tools"] == [tool] + assert body["input"] == [call, output] + + assert {:error, error} = + ResponsesModel.request_body(model, [], + input_items: [call, output], + reasoning: %{effort: "none"} + ) + + assert error.message =~ "apply_patch" + end + + test "Responses custom apply_patch output round-trips into the next request" do + model = ResponsesModel.new() + tool = Tools.apply_patch() + + response = %{ + "id" => "resp_1", + "model" => "deepseek-v4-flash", + "status" => "completed", + "output" => [ + %{ + "type" => "custom_tool_call", + "id" => "ct_1", + "call_id" => "patch_1", + "name" => "apply_patch", + "input" => "*** Begin Patch", + "status" => "completed" + } + ] + } + + assert {:ok, previous} = Messages.responses_to_message(response) + + output = %{ + "type" => "custom_tool_call_output", + "call_id" => "patch_1", + "output" => "Done!" + } + + assert {:ok, body} = + ResponsesModel.request_body(model, [previous], + tools: [tool], + input_items: [output] + ) + + assert [replayed_call, ^output] = body["input"] + assert replayed_call["type"] == "custom_tool_call" + assert replayed_call["call_id"] == "patch_1" + assert replayed_call["name"] == "apply_patch" + assert replayed_call["input"] == "*** Begin Patch" + end + + test "Responses named function and web-search choices must reference declared tools" do + model = ResponsesModel.new() + message = [Message.user("hello")] + + assert {:error, error} = + ResponsesModel.request_body(model, message, + tools: [@function_tool], + tool_choice: %{type: "function", name: "lookup"} + ) + + assert error.type == :unsupported_model_param + assert error.details.required == %{reasoning: %{effort: "none"}} + + assert {:ok, _body} = + ResponsesModel.request_body(model, message, + tools: [@function_tool], + reasoning: %{effort: "none"}, + tool_choice: %{type: "function", name: "lookup"} + ) + + assert {:error, error} = + ResponsesModel.request_body(model, message, + tools: [@function_tool], + reasoning: %{effort: "none"}, + tool_choice: %{type: "function", name: "missing"} + ) + + assert error.message =~ "declared tool" + + web_search = Tools.web_search() + + assert {:ok, _body} = + ResponsesModel.request_body(model, message, + tools: [web_search], + tool_choice: %{type: "web_search"} + ) + + assert {:error, error} = + ResponsesModel.request_body(model, message, tool_choice: %{type: "web_search"}) + + assert error.message =~ "declared tool" + end + + test "Responses forced custom choice requires reasoning effort none while auto remains available" do + model = ResponsesModel.new() + messages = [Message.user("apply a patch")] + tool = Tools.apply_patch() + + assert {:error, error} = + ResponsesModel.request_body(model, messages, + tools: [tool], + tool_choice: %{type: "custom", name: "apply_patch"} + ) + + assert error.type == :unsupported_model_param + assert error.details.required == %{reasoning: %{effort: "none"}} + + assert {:ok, forced_body} = + ResponsesModel.request_body(model, messages, + tools: [tool], + reasoning: %{effort: "none"}, + tool_choice: %{type: "custom", name: "apply_patch"} + ) + + assert forced_body["reasoning"] == %{"effort" => "none"} + + assert {:ok, auto_body} = + ResponsesModel.request_body(model, messages, + tools: [tool], + tool_choice: :auto + ) + + assert auto_body["tool_choice"] == "auto" + refute Map.has_key?(auto_body, "reasoning") + end +end diff --git a/test/beam_weaver/deepseek/provider_test.exs b/test/beam_weaver/deepseek/provider_test.exs new file mode 100644 index 0000000..ea93851 --- /dev/null +++ b/test/beam_weaver/deepseek/provider_test.exs @@ -0,0 +1,97 @@ +defmodule BeamWeaver.DeepSeek.ProviderTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Agent.ModelResolver + alias BeamWeaver.DeepSeek.ChatModel + alias BeamWeaver.DeepSeek.ResponsesModel + alias BeamWeaver.Models + alias BeamWeaver.Models.ProfileRegistry + + test "public facade exposes default and explicit API constructors" do + assert %ChatModel{} = BeamWeaver.DeepSeek.chat_model() + assert %ChatModel{} = BeamWeaver.DeepSeek.chat_completions_model() + assert %ResponsesModel{} = BeamWeaver.DeepSeek.responses_model() + end + + test "profiles expose current V4 limits, APIs, and canonical prices" do + assert {:ok, flash} = ProfileRegistry.fetch(:deepseek, "deepseek-v4-flash") + assert flash.max_input_tokens == 1_048_576 + assert flash.max_output_tokens == 393_216 + assert flash.chat_completions_api + assert flash.responses_api + assert flash.extra.input_price_per_mtok == 0.14 + assert flash.extra.cached_input_price_per_mtok == 0.0028 + assert flash.extra.output_price_per_mtok == 0.28 + + assert {:ok, pro} = ProfileRegistry.fetch(:deepseek, "deepseek-v4-pro") + assert pro.chat_completions_api + refute pro.responses_api + assert pro.extra.input_price_per_mtok == 0.435 + assert pro.extra.cached_input_price_per_mtok == 0.003625 + assert pro.extra.output_price_per_mtok == 0.87 + end + + test "initializer defaults to Chat, accepts atom/string API selection, and rejects Pro Responses early" do + assert {:ok, %ChatModel{model: "deepseek-v4-flash"}} = + Models.init_chat_model("deepseek:deepseek-v4-flash") + + for api <- [:responses, "responses"] do + assert {:ok, %ResponsesModel{model: "deepseek-v4-flash"}} = + Models.init_chat_model("deepseek:deepseek-v4-flash", api: api) + end + + for api <- [:chat, :chat_completions, "chat", "chat_completions"] do + assert {:ok, %ChatModel{}} = + Models.init_chat_model("deepseek:deepseek-v4-pro", api: api) + end + + assert {:error, error} = + Models.init_chat_model("deepseek:deepseek-v4-pro", api: :responses) + + assert error.type == :unsupported_model + assert error.details.api == :responses + + assert {:error, error} = + Models.init_chat_model("deepseek:deepseek-v4-flash", api: :unknown) + + assert error.type == :invalid_provider_option + end + + test "bare and retired model identifiers fail without ambiguous aliases" do + assert {:error, error} = Models.init_chat_model("deepseek-v4-flash") + assert error.type == :invalid_model + assert error.details.expected == "deepseek:deepseek-v4-flash" + + for retired <- ["deepseek-chat", "deepseek-reasoner"] do + assert {:error, error} = Models.init_chat_model("deepseek:" <> retired) + assert error.type == :deprecated_model + assert error.details.supported == ["deepseek-v4-flash", "deepseek-v4-pro"] + refute Map.has_key?(error.details, :replacement) + end + end + + test "DeepAgents resolver identifies both DeepSeek API models" do + assert {:ok, chat} = ModelResolver.resolve_model("deepseek:deepseek-v4-flash") + assert ModelResolver.get_model_provider(chat) == "deepseek" + assert ModelResolver.get_model_identifier(chat) == "deepseek-v4-flash" + + assert {:ok, responses} = + ModelResolver.resolve_model("deepseek:deepseek-v4-flash", api: :responses) + + assert ModelResolver.get_model_provider(responses) == "deepseek" + end + + test "high-level structs redact credentials" do + models = [ + {ChatModel.new(api_key: "chat-secret"), "chat-secret"}, + {ResponsesModel.new(api_key: "responses-secret"), "responses-secret"} + ] + + for {model, secret} <- models do + inspected = inspect(model, limit: :infinity) + + refute inspected =~ secret + assert inspected =~ ~s(api_key: "**REDACTED**") + end + end +end diff --git a/test/beam_weaver/deepseek/shared_boundaries_test.exs b/test/beam_weaver/deepseek/shared_boundaries_test.exs new file mode 100644 index 0000000..808ab08 --- /dev/null +++ b/test/beam_weaver/deepseek/shared_boundaries_test.exs @@ -0,0 +1,68 @@ +defmodule BeamWeaver.DeepSeek.SharedBoundariesTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Core.ContentBlock + alias BeamWeaver.Core.Message + alias BeamWeaver.DeepSeek.Error + alias BeamWeaver.DeepSeek.ResponsesModel + alias BeamWeaver.Provider.StructuredOutput + + test "Chat schema contracts validate locally without an explicit validator" do + schema = %{ + type: :object, + required: [:answer], + properties: %{answer: %{type: :string}} + } + + opts = [response_format: %{type: :json_schema, name: "Answer", schema: schema}] + parse_opts = [error_module: Error, provider_name: "DeepSeek"] + + assert {:ok, message} = + StructuredOutput.maybe_parse(Message.assistant(~s({"answer":"pong"})), opts, parse_opts) + + assert message.metadata.parsed == %{"answer" => "pong"} + + assert {:error, %Error{type: :structured_output_parse_error}} = + StructuredOutput.maybe_parse(Message.assistant(~s({"answer":false})), opts, parse_opts) + end + + test "Responses serializes typed and returned plain reasoning for full-history replay" do + typed = Message.assistant([ContentBlock.reasoning("typed chain")]) + + assert {:ok, typed_body} = ResponsesModel.request_body(ResponsesModel.new(), [typed]) + + assert typed_body["input"] == [ + %{ + "type" => "reasoning", + "content" => [%{"type" => "reasoning_text", "text" => "typed chain"}] + } + ] + + returned = + Message.assistant([ + %{ + type: :reasoning, + id: "rs_1", + summary: [], + reasoning: "plain chain", + raw_provider_block: %{ + "type" => "reasoning", + "id" => "rs_1", + "summary" => [], + "content" => [%{"type" => "reasoning_text", "text" => "plain chain"}] + } + } + ]) + + assert {:ok, replay_body} = ResponsesModel.request_body(ResponsesModel.new(), [returned]) + + assert replay_body["input"] == [ + %{ + "type" => "reasoning", + "id" => "rs_1", + "summary" => [], + "content" => [%{"type" => "reasoning_text", "text" => "plain chain"}] + } + ] + end +end diff --git a/test/beam_weaver/models/initializer_test.exs b/test/beam_weaver/models/initializer_test.exs index ded3771..2c2ff80 100644 --- a/test/beam_weaver/models/initializer_test.exs +++ b/test/beam_weaver/models/initializer_test.exs @@ -378,6 +378,12 @@ defmodule BeamWeaver.Models.InitializerTest do assert sol.extra.cached_input_price_per_mtok == 0.50 assert sol.extra.cache_write_30m_price_per_mtok == 6.25 assert sol.extra.output_price_per_mtok == 30.00 + assert sol.extra.pricing_source_url == "https://developers.openai.com/api/docs/pricing" + assert sol.extra.pricing_modes == [:standard, :batch, :flex, :fast, :priority] + assert sol.extra.batch_price_multiplier == 0.5 + assert sol.extra.flex_price_multiplier == 0.5 + assert sol.extra.fast_mode_price_multiplier == 2.0 + assert sol.extra.fast_mode_service_tiers == [:fast, :priority] assert sol.extra.regional_processing_multiplier == 1.1 assert sol.extra.default_reasoning_effort == :medium assert sol.extra.reasoning_efforts == [:none, :low, :medium, :high, :xhigh, :max] @@ -387,6 +393,20 @@ defmodule BeamWeaver.Models.InitializerTest do assert gpt56_alias.id == "gpt-5.6" assert gpt56_alias.extra.canonical_model == "gpt-5.6-sol" + assert {:ok, terra} = ProfileRegistry.fetch(:openai, "gpt-5.6-terra") + assert terra.last_updated == "2026-07-30" + assert terra.extra.input_price_per_mtok == 2.00 + assert terra.extra.cached_input_price_per_mtok == 0.20 + assert terra.extra.cache_write_30m_price_per_mtok == 2.50 + assert terra.extra.output_price_per_mtok == 12.00 + + assert {:ok, luna} = ProfileRegistry.fetch(:openai, "gpt-5.6-luna") + assert luna.last_updated == "2026-07-30" + assert luna.extra.input_price_per_mtok == 0.20 + assert luna.extra.cached_input_price_per_mtok == 0.02 + assert luna.extra.cache_write_30m_price_per_mtok == 0.25 + assert luna.extra.output_price_per_mtok == 1.20 + assert {:ok, unknown} = ProfileRegistry.fetch(:openai, "future-non-family-model") assert unknown.extra.unknown == true assert :ok = ParamPolicy.validate(unknown, [reasoning: %{effort: "low"}], nil) @@ -405,7 +425,16 @@ defmodule BeamWeaver.Models.InitializerTest do end test "profile registry exposes deterministic checked-in profile introspection" do - assert ProfileRegistry.providers() == [:anthropic, :fake, :google, :moonshot, :openai, :xai, :zai] + assert ProfileRegistry.providers() == [ + :anthropic, + :deepseek, + :fake, + :google, + :moonshot, + :openai, + :xai, + :zai + ] all = ProfileRegistry.all() openai = ProfileRegistry.profiles(:openai) @@ -427,7 +456,7 @@ defmodule BeamWeaver.Models.InitializerTest do assert Enum.any?( openai, - &(&1.id == "gpt-5.6-luna" and &1.extra.input_price_per_mtok == 1.00) + &(&1.id == "gpt-5.6-luna" and &1.extra.input_price_per_mtok == 0.20) ) assert Enum.any?(openai, &(&1.id == "gpt-5.5" and &1.tool_calling)) diff --git a/test/beam_weaver/models/usage_cost_test.exs b/test/beam_weaver/models/usage_cost_test.exs new file mode 100644 index 0000000..42aaeb9 --- /dev/null +++ b/test/beam_weaver/models/usage_cost_test.exs @@ -0,0 +1,73 @@ +defmodule BeamWeaver.Models.UsageCostTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Models.Profile + alias BeamWeaver.Models.UsageCost + + @profile Profile.new(%{ + provider: :test, + id: "priced-model", + extra: %{ + input_price_per_mtok: 2.0, + cached_input_price_per_mtok: 0.5, + output_price_per_mtok: 4.0 + } + }) + + test "derives uncached input from total input and cached tokens" do + costs = + UsageCost.calculate(@profile, %{ + input_tokens: 100, + output_tokens: 20, + input_token_details: %{cache_read: 40}, + output_token_details: %{reasoning: 15} + }) + + assert_in_delta costs.input_cost_details.uncached, 0.00012, 1.0e-12 + assert_in_delta costs.input_cost_details.cache_read, 0.00002, 1.0e-12 + assert_in_delta costs.input_cost, 0.00014, 1.0e-12 + assert_in_delta costs.output_cost, 0.00008, 1.0e-12 + assert_in_delta costs.total_cost, 0.00022, 1.0e-12 + assert costs.output_cost_details == %{text: costs.output_cost} + end + + test "prefers explicit cache hit and miss counts without billing reasoning twice" do + costs = + UsageCost.calculate(@profile, %{ + "prompt_tokens" => 100, + "completion_tokens" => 20, + "prompt_cache_hit_tokens" => 30, + "prompt_cache_miss_tokens" => 10, + "completion_tokens_details" => %{"reasoning_tokens" => 20} + }) + + assert_in_delta costs.input_cost_details.uncached, 0.00002, 1.0e-12 + assert_in_delta costs.input_cost_details.cache_read, 0.000015, 1.0e-12 + assert_in_delta costs.output_cost, 0.00008, 1.0e-12 + assert_in_delta costs.total_cost, 0.000115, 1.0e-12 + end + + test "uses the normal input rate when a profile has no cached-input rate" do + profile = %{ + "extra" => %{ + "input_price_per_mtok" => 1.0, + "output_price_per_mtok" => 3.0 + } + } + + costs = + UsageCost.calculate(profile, %{ + prompt_tokens: 10, + completion_tokens: 2, + prompt_tokens_details: %{cached_tokens: 4} + }) + + assert_in_delta costs.input_cost, 0.00001, 1.0e-12 + assert_in_delta costs.output_cost, 0.000006, 1.0e-12 + end + + test "returns nil when canonical profile pricing is unavailable" do + assert UsageCost.calculate(Profile.new(%{provider: :test, id: "free"}), %{input_tokens: 10}) == nil + assert UsageCost.calculate(nil, %{input_tokens: 10}) == nil + end +end diff --git a/test/beam_weaver/open_ai/chat_model_test.exs b/test/beam_weaver/open_ai/chat_model_test.exs index 525970b..33b4f0e 100644 --- a/test/beam_weaver/open_ai/chat_model_test.exs +++ b/test/beam_weaver/open_ai/chat_model_test.exs @@ -617,6 +617,15 @@ defmodule BeamWeaver.OpenAI.ChatModelTest do assert Message.text(response) == "options accepted" end + test "Responses API serializes OpenAI Fast mode" do + model = ChatModel.new(model: "gpt-5.6-sol") + + assert {:ok, body} = + ChatModel.request_body(model, [Message.user("fast option check")], service_tier: :fast) + + assert body["service_tier"] == "fast" + end + test "Responses request builder applies store false replay sanitization before extra body merge" do model = ChatModel.new(model: "gpt-5.5") diff --git a/test/beam_weaver/open_ai/messages_test.exs b/test/beam_weaver/open_ai/messages_test.exs index 31edb3b..30dd0ee 100644 --- a/test/beam_weaver/open_ai/messages_test.exs +++ b/test/beam_weaver/open_ai/messages_test.exs @@ -393,6 +393,70 @@ defmodule BeamWeaver.OpenAI.MessagesTest do refute input |> BeamWeaver.JSON.encode!() |> String.contains?("private summary text") end + test "serializes typed and map reasoning blocks as replayable plain-text items" do + message = + Message.assistant([ + ContentBlock.reasoning("typed reasoning"), + %{type: :reasoning, id: "rs_atom", reasoning: "atom-map reasoning", summary: []}, + %{"type" => "reasoning", "id" => "rs_string", "reasoning" => "string-map reasoning"} + ]) + + assert {:ok, input} = Messages.to_responses_input([message], store: false) + + assert input == [ + %{ + "type" => "reasoning", + "content" => [%{"type" => "reasoning_text", "text" => "typed reasoning"}] + }, + %{ + "type" => "reasoning", + "summary" => [], + "content" => [%{"type" => "reasoning_text", "text" => "atom-map reasoning"}] + }, + %{ + "type" => "reasoning", + "content" => [%{"type" => "reasoning_text", "text" => "string-map reasoning"}] + } + ] + end + + test "round-trips Responses plain reasoning content without internal provider fields" do + response = %{ + "id" => "resp_reasoning_replay", + "status" => "completed", + "output" => [ + %{ + "type" => "reasoning", + "id" => "rs_1", + "status" => "completed", + "summary" => [], + "content" => [ + %{"type" => "reasoning_text", "text" => "first "}, + %{"type" => "reasoning_text", "text" => "second"} + ] + } + ] + } + + assert {:ok, message} = Messages.response_to_message(response) + assert [%{type: :reasoning, reasoning: "first second"}] = message.content + assert {:ok, input} = Messages.to_responses_input([message], store: false) + + assert input == [ + %{ + "type" => "reasoning", + "status" => "completed", + "summary" => [], + "content" => [ + %{"type" => "reasoning_text", "text" => "first "}, + %{"type" => "reasoning_text", "text" => "second"} + ] + } + ] + + refute input |> BeamWeaver.JSON.encode!() |> String.contains?("raw_provider_block") + end + test "store false sanitizes replay-only Responses item ids and non-replayable blocks" do message = Message.assistant([ @@ -769,6 +833,7 @@ defmodule BeamWeaver.OpenAI.MessagesTest do %{ "type" => "reasoning", "id" => "rs_1", + "content" => [], "summary" => [ %{"type" => "summary_text", "text" => "looked up docs"} ] diff --git a/test/beam_weaver/open_ai/streaming_test.exs b/test/beam_weaver/open_ai/streaming_test.exs index 57aa682..10981cc 100644 --- a/test/beam_weaver/open_ai/streaming_test.exs +++ b/test/beam_weaver/open_ai/streaming_test.exs @@ -75,6 +75,185 @@ defmodule BeamWeaver.OpenAI.StreamingTest do ) end + test "typed events preserve incomplete and failed Responses terminals" do + incomplete_response = %{ + "id" => "resp_incomplete", + "status" => "incomplete", + "incomplete_details" => %{"reason" => "max_output_tokens"}, + "output" => [], + "usage" => %{"input_tokens" => 7, "output_tokens" => 3, "total_tokens" => 10} + } + + incomplete_body = """ + event: response.incomplete + data: {"type":"response.incomplete","sequence_number":4,"response":#{BeamWeaver.JSON.encode!(incomplete_response)}} + """ + + assert [ + %Envelope{ + event: %Events.Done{ + result: ^incomplete_response, + usage: %{"total_tokens" => 10} = usage, + metadata: incomplete_metadata + } + } + ] = Streaming.typed_events(incomplete_body) + + assert usage["input_tokens"] == 7 + + assert incomplete_metadata == %{ + event_type: "response.incomplete", + sequence_number: 4, + status: "incomplete", + usage: incomplete_response["usage"] + } + + failed_response = %{ + "id" => "resp_failed", + "status" => "failed", + "error" => %{"code" => "server_error", "message" => "generation failed"}, + "output" => [], + "usage" => %{"input_tokens" => 2, "output_tokens" => 0, "total_tokens" => 2} + } + + failed_data = %{ + "type" => "response.failed", + "sequence_number" => 9, + "response" => failed_response + } + + failed_body = "event: response.failed\ndata: #{BeamWeaver.JSON.encode!(failed_data)}\n\n" + + assert [ + %Envelope{ + event: %Events.Error{ + error: %BeamWeaver.Core.Error{ + type: :response_failed, + message: "generation failed", + details: failed_details + }, + metadata: failed_metadata + } + } + ] = Streaming.typed_events(failed_body) + + assert failed_details.event == failed_data + assert failed_details.response == failed_response + assert failed_details.status == "failed" + assert failed_details.usage == failed_response["usage"] + + assert failed_metadata == %{ + event_type: "response.failed", + sequence_number: 9, + status: "failed", + usage: failed_response["usage"] + } + end + + test "typed events preserve hosted and custom output-item lifecycle without duplicating message chunks" do + body = """ + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ws_1","type":"web_search_call","status":"in_progress"}} + + event: response.web_search_call.in_progress + data: {"type":"response.web_search_call.in_progress","output_index":0,"item_id":"ws_1","sequence_number":1} + + event: response.web_search_call.searching + data: {"type":"response.web_search_call.searching","output_index":0,"item_id":"ws_1","sequence_number":2} + + event: response.web_search_call.failed + data: {"type":"response.web_search_call.failed","output_index":0,"item_id":"ws_1","sequence_number":3,"status":"failed","error":{"code":"search_failed","message":"upstream unavailable"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"id":"ws_1","type":"web_search_call","status":"failed"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":1,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":""}} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","output_index":1,"item_id":"fc_1","delta":"{}"} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":2,"item":{"id":"ct_1","type":"custom_tool_call","call_id":"custom_1","name":"shell","input":""}} + + event: response.custom_tool_call_input.delta + data: {"type":"response.custom_tool_call_input.delta","output_index":2,"item_id":"ct_1","delta":"echo hi"} + + event: response.custom_tool_call_input.done + data: {"type":"response.custom_tool_call_input.done","output_index":2,"item_id":"ct_1","input":"echo hi"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":2,"item":{"id":"ct_1","type":"custom_tool_call","call_id":"custom_1","name":"shell","input":"echo hi","status":"completed"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":3,"item":{"id":"patch_1","type":"apply_patch_call","status":"in_progress"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":3,"item":{"id":"patch_1","type":"apply_patch_call","status":"completed","operation":{"type":"create_file","path":"a.txt"}}} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"thinking"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"answer"} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"total_tokens":4}}} + """ + + events = Streaming.typed_events(body) + + custom_payloads = + Enum.flat_map(events, fn + %Envelope{event: %Events.Custom{payload: payload}} -> [payload] + _event -> [] + end) + + assert Enum.map(custom_payloads, &get_in(&1, ["data", "type"])) == [ + "response.output_item.added", + "response.web_search_call.in_progress", + "response.web_search_call.searching", + "response.web_search_call.failed", + "response.output_item.done", + "response.output_item.added", + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.done", + "response.output_item.done", + "response.output_item.added", + "response.output_item.done" + ] + + assert get_in(Enum.at(custom_payloads, 3), ["data", "error", "code"]) == "search_failed" + + assert Enum.map( + Enum.filter(custom_payloads, &(get_in(&1, ["data", "type"]) == "response.output_item.added")), + &get_in(&1, ["data", "item", "type"]) + ) == ["web_search_call", "custom_tool_call", "apply_patch_call"] + + assert ["answer"] == + Enum.flat_map(events, fn + %Envelope{event: %Events.Token{text: text}} -> [text] + _event -> [] + end) + + assert 2 == Enum.count(events, &match?(%Envelope{event: %Events.ToolCallChunk{}}, &1)) + + assert 1 == + Enum.count(events, fn + %Envelope{ + event: %Events.MessageChunk{ + chunk: %Messages.AIChunk{content: [%ContentBlock.Reasoning{}]} + } + } -> + true + + _event -> + false + end) + + assert Enum.any?(events, &match?(%Envelope{event: %Events.Done{}}, &1)) + end + test "typed events tolerate in-memory prompt cache retention literal drift" do # Upstream reference: body = """ diff --git a/test/beam_weaver/provider/chat_runtime_test.exs b/test/beam_weaver/provider/chat_runtime_test.exs index 2fc3950..3e38273 100644 --- a/test/beam_weaver/provider/chat_runtime_test.exs +++ b/test/beam_weaver/provider/chat_runtime_test.exs @@ -3,6 +3,7 @@ defmodule BeamWeaver.Provider.ChatRuntimeTest do alias BeamWeaver.Provider.ChatRuntime alias BeamWeaver.Provider.ChatRuntime.Adapter + alias BeamWeaver.Core.Message defp base_adapter(overrides) do defaults = %{ @@ -38,4 +39,43 @@ defmodule BeamWeaver.Provider.ChatRuntimeTest do assert {:ok, _stream} = ChatRuntime.stream_events(%{}, [], [], adapter) end end + + describe "invoke/4 response parsing" do + test "passes the model to three-arity provider parsers" do + parent = self() + model = %{include_response_headers: false, id: :configured_model} + message = Message.assistant("ok") + + adapter = + base_adapter(%{ + decode: fn _response, _opts -> {:ok, message} end, + parse: fn parsed_model, parsed_message, opts -> + send(parent, {:parsed, parsed_model, parsed_message, opts}) + {:ok, parsed_message} + end + }) + + assert {:ok, ^message} = ChatRuntime.invoke(model, [], [response_format: %{type: :json}], adapter) + + assert_received {:parsed, ^model, ^message, [include_response_headers: false, response_format: %{type: :json}]} + end + + test "keeps two-arity provider parsers backward compatible" do + parent = self() + model = %{include_response_headers: false} + message = Message.assistant("ok") + + adapter = + base_adapter(%{ + decode: fn _response, _opts -> {:ok, message} end, + parse: fn parsed_message, opts -> + send(parent, {:parsed, parsed_message, opts}) + {:ok, parsed_message} + end + }) + + assert {:ok, ^message} = ChatRuntime.invoke(model, [], [], adapter) + assert_received {:parsed, ^message, [include_response_headers: false]} + end + end end diff --git a/test/beam_weaver/provider/json_object_format_test.exs b/test/beam_weaver/provider/json_object_format_test.exs new file mode 100644 index 0000000..6036f59 --- /dev/null +++ b/test/beam_weaver/provider/json_object_format_test.exs @@ -0,0 +1,86 @@ +defmodule BeamWeaver.Provider.JsonObjectFormatTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Core.Error + alias BeamWeaver.Provider.JsonObjectFormat + + test "normalizes JSON-object formats without injecting an instruction" do + assert {:ok, %{"type" => "json_object"}, nil} = + JsonObjectFormat.normalize(%{type: :json_object}) + + assert {:ok, %{"type" => "json_object"}, nil} = + JsonObjectFormat.normalize(%{"type" => :json_object}) + + assert {:ok, nil, nil} = JsonObjectFormat.normalize(nil) + end + + test "renders schema contracts and inserts them after leading system messages" do + schema = %{ + type: "object", + required: ["answer"], + properties: %{answer: %{type: "string"}} + } + + assert {:ok, %{"type" => "json_object"}, instruction} = + JsonObjectFormat.normalize(%{name: "Answer", schema: schema, strict: false}) + + assert instruction["content"] =~ "Validate against this schema." + assert instruction["content"] =~ "Required keys: answer" + assert instruction["content"] =~ ~s("answer") + + messages = [ + %{"role" => "system", "content" => "existing"}, + %{"role" => "user", "content" => "question"} + ] + + assert JsonObjectFormat.inject_instruction(messages, instruction) == [ + hd(messages), + instruction, + List.last(messages) + ] + end + + test "recursively normalizes atom schema keys and values before rendering" do + schema = %{ + type: :object, + required: [:answer], + properties: %{ + answer: %{type: :string, enum: [:yes, :no]}, + details: %{ + type: :object, + properties: %{attempts: %{type: :integer}} + } + } + } + + assert {:ok, %{"type" => "json_object"}, %{"content" => content}} = + JsonObjectFormat.normalize(%{name: "Answer", schema: schema}) + + [_, rendered_schema] = String.split(content, "JSON Schema:\n", parts: 2) + + assert BeamWeaver.JSON.decode!(rendered_schema) == %{ + "type" => "object", + "required" => ["answer"], + "properties" => %{ + "answer" => %{"type" => "string", "enum" => ["yes", "no"]}, + "details" => %{ + "type" => "object", + "properties" => %{"attempts" => %{"type" => "integer"}} + } + } + } + end + + test "returns the configured provider error for unsupported formats" do + assert {:error, %Error{} = error} = + JsonObjectFormat.normalize(%{type: "json_schema"}, + error_message: "JSON object only", + error_details: %{provider: :test} + ) + + assert error.type == :invalid_response_format + assert error.message == "JSON object only" + assert error.details.provider == :test + assert error.details.supported == [%{"type" => "json_object"}] + end +end diff --git a/test/beam_weaver/provider/open_ai_compatible_streaming_test.exs b/test/beam_weaver/provider/open_ai_compatible_streaming_test.exs new file mode 100644 index 0000000..11f1121 --- /dev/null +++ b/test/beam_weaver/provider/open_ai_compatible_streaming_test.exs @@ -0,0 +1,68 @@ +defmodule BeamWeaver.Provider.OpenAICompatibleStreamingTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Core.Message + alias BeamWeaver.Core.Messages.ToolCall + alias BeamWeaver.Provider.OpenAICompatibleStreaming, as: Streaming + alias BeamWeaver.Stream.Events + alias BeamWeaver.ZAI.Error + + test "one reducer reconstructs reasoning, text, tools, finish reason, usage, and unknown deltas" do + body = """ + data: {"id":"chatcmpl_1","model":"test-model","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]} + + data: {"id":"chatcmpl_1","choices":[{"index":0,"delta":{"content":"do","tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\\"id\\":"}}]},"finish_reason":null}]} + + data: {"id":"chatcmpl_1","choices":[{"index":0,"delta":{"content":"ne","tool_calls":[{"index":0,"function":{"arguments":"1}"}}],"provider_field":"kept"},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}} + + data: [DONE] + """ + + assert Streaming.text_deltas(body) == ["do", "ne"] + assert {:ok, message} = Streaming.stream_body_to_message(body, config()) + assert message.id == "chatcmpl_1" + assert Message.text(message) == "done" + assert message.status == "tool_calls" + assert message.metadata.reasoning_content == "think " + assert message.metadata.test_delta == %{"provider_field" => "kept"} + assert message.usage_metadata == %{input_tokens: 2, output_tokens: 3, total_tokens: 5} + assert [%ToolCall{name: "lookup", args: %{"id" => 1}}] = message.tool_calls + + events = Streaming.typed_events(body, config()) + assert Enum.any?(events, &match?(%{event: %Events.Token{text: "do"}}, &1)) + assert Enum.any?(events, &match?(%{event: %Events.ToolCallChunk{}}, &1)) + assert Enum.any?(events, &match?(%{event: %Events.Done{usage: %{"total_tokens" => 5}}}, &1)) + assert Enum.all?(events, &(&1.metadata.provider == :test_provider)) + end + + test "invalid collected streams return the configured provider error" do + assert {:error, %Error{} = error} = Streaming.stream_body_to_message(:not_a_body, config()) + assert error.type == :invalid_response + assert error.message == "Test provider stream body must be binary" + end + + defp config do + %{ + provider: :test_provider, + provider_name: "Test provider", + error_module: Error, + usage_metadata: fn %{"usage" => usage} -> + %{ + input_tokens: usage["prompt_tokens"], + output_tokens: usage["completion_tokens"], + total_tokens: usage["total_tokens"] + } + end, + stream_metadata: fn events, message, _opts -> + id = Enum.find_value(events, &get_in(&1, ["data", "id"])) + reasoning = message.metadata[:reasoning_content] + + %{provider: :test_provider, id: id, reasoning_content: reasoning} + end, + choice_usage: false, + include_chunk_id: true, + reasoning_index: 0, + unknown_delta_key: :test_delta + } + end +end diff --git a/test/beam_weaver/provider/redacted_inspect_test.exs b/test/beam_weaver/provider/redacted_inspect_test.exs new file mode 100644 index 0000000..6145be5 --- /dev/null +++ b/test/beam_weaver/provider/redacted_inspect_test.exs @@ -0,0 +1,30 @@ +defmodule BeamWeaver.Provider.RedactedInspectTest do + use ExUnit.Case, async: true + + alias BeamWeaver.Provider.RedactedInspect + alias BeamWeaver.Transport.Redactor + + defmodule ExampleClient do + defstruct [:api_key, :endpoint, :transport_opts] + end + + test "renders provider structs while recursively redacting secrets" do + client = %ExampleClient{ + api_key: "sk-provider-secret", + endpoint: "https://example.test", + transport_opts: [headers: [{"x-api-key", "nested-secret"}]] + } + + rendered = + client + |> RedactedInspect.redacted_struct(%Inspect.Opts{limit: :infinity}) + |> Inspect.Algebra.format(100) + |> IO.iodata_to_binary() + + assert rendered =~ "BeamWeaver.Provider.RedactedInspectTest.ExampleClient" + assert rendered =~ "https://example.test" + assert rendered =~ Redactor.redacted() + refute rendered =~ "sk-provider-secret" + refute rendered =~ "nested-secret" + end +end diff --git a/test/beam_weaver/provider/registry_test.exs b/test/beam_weaver/provider/registry_test.exs index a06c7be..0648b67 100644 --- a/test/beam_weaver/provider/registry_test.exs +++ b/test/beam_weaver/provider/registry_test.exs @@ -70,7 +70,22 @@ defmodule BeamWeaver.Provider.RegistryTest do end test "built-in provider surface exposes expected provider IDs and profiles" do - assert Registry.providers() == [:anthropic, :fake, :google, :moonshot, :openai, :xai, :zai] + assert Registry.providers() == [ + :anthropic, + :deepseek, + :fake, + :google, + :moonshot, + :openai, + :xai, + :zai + ] + + assert {:ok, deepseek} = Registry.profile(:deepseek, "deepseek-v4-flash") + assert deepseek.provider == :deepseek + assert deepseek.responses_api + assert deepseek.max_input_tokens == 1_048_576 + assert deepseek.max_output_tokens == 393_216 assert {:ok, flash} = Registry.profile(:google, "gemini-3.6-flash") assert flash.provider == :google @@ -149,6 +164,8 @@ defmodule BeamWeaver.Provider.RegistryTest do assert Compatibility.supports?({:zai, "glm-5.2"}, :tool_calling) assert Compatibility.supports?({:zai, "glm-5.2"}, :structured_output) assert Compatibility.supports?({:zai, "glm-5.2"}, :reasoning) + assert Compatibility.supports?({:deepseek, "deepseek-v4-flash"}, :reasoning) + assert Compatibility.supports?({:deepseek, "deepseek-v4-pro"}, :tool_calling) refute Compatibility.supports?({:xai, "grok-2"}, :reasoning) end diff --git a/test/beam_weaver/provider/response_decoder_test.exs b/test/beam_weaver/provider/response_decoder_test.exs index 34f6926..8e1bcb0 100644 --- a/test/beam_weaver/provider/response_decoder_test.exs +++ b/test/beam_weaver/provider/response_decoder_test.exs @@ -22,7 +22,7 @@ defmodule BeamWeaver.Provider.ResponseDecoderTest do ["data: {\"text\":\"hel\"}\n\n", "data: {\"text\":\"lo\"}\n\n"] |> Enum.reduce(acc, fn chunk, acc -> reducer.(acc, chunk) end) - {:ok, Response.new(status: 200), acc} + {:ok, Response.new(status: 200, headers: [{"x-stream-request-id", "stream-1"}], metadata: %{source: :test}), acc} end end @@ -42,6 +42,24 @@ defmodule BeamWeaver.Provider.ResponseDecoderTest do end end + defmodule ErrorStreamTransport do + @behaviour BeamWeaver.Transport + + @impl true + def request(%Request{}, _opts), do: {:ok, Response.new(status: 429, body: %{})} + + @impl true + def stream_reduce(%Request{}, _opts, acc, _reducer) do + {:ok, + Response.new( + status: 429, + headers: [{"retry-after", "2"}], + body: ~s({"error":{"message":"slow down"}}), + metadata: %{source: :test} + ), acc} + end + end + test "json decodes successful responses and can attach response headers" do response = Response.new( @@ -118,6 +136,73 @@ defmodule BeamWeaver.Provider.ResponseDecoderTest do assert_received {:stream_timeout, 123} end + test "http client shared SSE helper invokes the opt-in response callback once and stays lazy" do + parent = self() + + client = + HTTPClient.new( + endpoint: "https://example.test/stream", + transport: StreamTransport, + transport_opts: [parent: parent], + timeout: 123 + ) + + assert {:ok, stream} = + HTTPClient.stream_sse( + client, + %{"stream" => true}, + [on_response: fn response -> send(parent, {:lazy_sse_response, response}) end], + fn events -> Enum.map(events, &get_in(&1, ["data", "text"])) end, + &ResponseDecoder.json(&1, provider_name: "TestProvider") + ) + + refute_received {:lazy_sse_response, _response} + assert Enum.to_list(stream) == ["hel", "lo"] + + assert_received {:lazy_sse_response, + %Response{ + status: 200, + headers: [{"x-stream-request-id", "stream-1"}], + metadata: %{source: :test} + }} + + refute_received {:lazy_sse_response, _response} + end + + test "http client shared SSE helper invokes the response callback for HTTP failures" do + parent = self() + + client = + HTTPClient.new( + endpoint: "https://example.test/stream", + transport: ErrorStreamTransport + ) + + assert {:ok, stream} = + HTTPClient.stream_sse( + client, + %{"stream" => true}, + [on_response: fn response -> send(parent, {:failed_sse_response, response}) end], + fn _events -> [] end, + &ResponseDecoder.json(&1, provider_name: "TestProvider") + ) + + assert [ + %BeamWeaver.Stream.Events.Error{ + error: %Error{type: :http_error, message: "slow down"} + } + ] = Enum.to_list(stream) + + assert_received {:failed_sse_response, + %Response{ + status: 429, + headers: [{"retry-after", "2"}], + body: ~s({"error":{"message":"slow down"}}) + }} + + refute_received {:failed_sse_response, _response} + end + test "http client attaches redacted metadata for Req and Finch telemetry" do client = HTTPClient.new( diff --git a/test/beam_weaver/provider/structured_output_test.exs b/test/beam_weaver/provider/structured_output_test.exs index 784dd9c..a77fd5f 100644 --- a/test/beam_weaver/provider/structured_output_test.exs +++ b/test/beam_weaver/provider/structured_output_test.exs @@ -51,6 +51,45 @@ defmodule BeamWeaver.Provider.StructuredOutputTest do ) end + test "derives local validation from schema-shaped response formats" do + schema = %{ + type: :object, + required: [:answer], + properties: %{ + answer: %{type: :string, enum: [:pong]} + } + } + + formats = [ + %{name: "Answer", schema: schema}, + %{"type" => "json_schema", "json_schema" => %{"name" => "Answer", "schema" => schema}}, + {"Answer", schema}, + schema + ] + + for format <- formats do + assert {:ok, parsed_message} = + StructuredOutput.maybe_parse( + Message.assistant(~s({"answer":"pong"})), + [response_format: format], + error_module: OpenAI.Error, + provider_name: "OpenAI" + ) + + assert parsed_message.metadata.parsed == %{"answer" => "pong"} + end + + for invalid_json_object <- [~s({"answer":42}), ~s({}), ~s([])] do + assert {:error, %OpenAI.Error{type: :structured_output_parse_error}} = + StructuredOutput.maybe_parse( + Message.assistant(invalid_json_object), + [response_format: %{name: "Answer", schema: schema}], + error_module: OpenAI.Error, + provider_name: "OpenAI" + ) + end + end + test "truncated structured output reports finish reason without storing the full response" do message = Message.assistant(String.duplicate("{", 10_000), diff --git a/test/beam_weaver/provider_conformance/provider_conformance_test.exs b/test/beam_weaver/provider_conformance/provider_conformance_test.exs index f61ead4..3246962 100644 --- a/test/beam_weaver/provider_conformance/provider_conformance_test.exs +++ b/test/beam_weaver/provider_conformance/provider_conformance_test.exs @@ -388,6 +388,172 @@ defmodule BeamWeaver.ProviderConformanceTest do end end + describe "DeepSeek Chat Completions fixtures" do + test "basic chat normalizes reasoning, cache usage, cost, and provider metadata" do + model = Fixtures.model(:deepseek, "basic_chat") + + assert {:ok, message} = ChatModel.invoke(model, [Message.user("Reply with exactly: pong")]) + + Fixtures.assert_request!(:deepseek, "basic_chat") + Fixtures.assert_message!(message, Fixtures.load!(:deepseek, "basic_chat")["expected"]["message"]) + end + + test "JSON object mode is locally validated as structured output" do + model = Fixtures.model(:deepseek, "provider_structured_success") + + assert {:ok, message} = + ChatModel.invoke(model, [Message.user("Return a structured answer where answer is exactly pong.")], + response_format: %{name: "answer_output", schema: Fixtures.answer_schema()} + ) + + Fixtures.assert_request!(:deepseek, "provider_structured_success") + + expected = + Fixtures.load!(:deepseek, "provider_structured_success")["expected"]["message"] + |> Map.delete("text") + + Fixtures.assert_message!( + message, + expected + ) + + assert message.metadata.parsed == %{"answer" => "pong"} + end + + test "streaming reconstructs reasoning and final cache usage" do + model = Fixtures.model(:deepseek, "streaming_usage") + + assert {:ok, message} = + model.__struct__.stream_response(model, [Message.user("Reply with exactly: streamed pong")]) + + Fixtures.assert_request!(:deepseek, "streaming_usage") + Fixtures.assert_message!(message, Fixtures.load!(:deepseek, "streaming_usage")["expected"]["message"]) + end + + test "fragmented streamed tool arguments merge by choice index" do + model = Fixtures.model(:deepseek, "streaming_tool_call") + + assert {:ok, message} = + model.__struct__.stream_response( + model, + [Message.user("Call get_weather for Tokyo. Do not answer directly.")], + tools: [Fixtures.weather_tool()], + tool_choice: "auto", + tool_stream: true, + thinking: %{type: "disabled"} + ) + + Fixtures.assert_request!(:deepseek, "streaming_tool_call") + + Fixtures.assert_message!( + message, + Fixtures.load!(:deepseek, "streaming_tool_call")["expected"]["message"] + ) + end + end + + describe "DeepSeek Responses fixtures" do + test "basic stateless response normalizes text, usage, cost, and metadata" do + model = Fixtures.model(:deepseek_responses, "basic_chat") + + assert {:ok, message} = ChatModel.invoke(model, [Message.user("Reply with exactly: pong")]) + + Fixtures.assert_request!(:deepseek_responses, "basic_chat") + + Fixtures.assert_message!( + message, + Fixtures.load!(:deepseek_responses, "basic_chat")["expected"]["message"] + ) + end + + test "function calls retain Responses item and call identifiers" do + model = Fixtures.model(:deepseek_responses, "single_tool_call") + + assert {:ok, message} = + ChatModel.invoke( + model, + [Message.user("Call get_weather for Tokyo. Do not answer directly.")], + tools: [Fixtures.weather_tool()] + ) + + Fixtures.assert_request!(:deepseek_responses, "single_tool_call") + + Fixtures.assert_message!( + message, + Fixtures.load!(:deepseek_responses, "single_tool_call")["expected"]["message"] + ) + end + + test "native JSON Schema output parses into structured metadata" do + model = Fixtures.model(:deepseek_responses, "provider_structured_success") + + assert {:ok, message} = + ChatModel.invoke(model, [Message.user("Return a structured answer where answer is exactly pong.")], + response_format: %{name: "answer_output", schema: Fixtures.answer_schema()} + ) + + Fixtures.assert_request!(:deepseek_responses, "provider_structured_success") + + expected = + Fixtures.load!(:deepseek_responses, "provider_structured_success")["expected"]["message"] + |> Map.delete("text") + + Fixtures.assert_message!( + message, + expected + ) + + assert message.metadata.parsed == %{"answer" => "pong"} + end + + test "named-event streaming reconstructs the completed response" do + model = Fixtures.model(:deepseek_responses, "streaming_usage") + + assert {:ok, message} = + model.__struct__.stream_response(model, [Message.user("Reply with exactly: streamed pong")]) + + Fixtures.assert_request!(:deepseek_responses, "streaming_usage") + + Fixtures.assert_message!( + message, + Fixtures.load!(:deepseek_responses, "streaming_usage")["expected"]["message"] + ) + end + end + + describe "capture sanitization" do + test "canonicalizes IDs and removes timestamps inside SSE transcripts" do + path = + Path.join( + System.tmp_dir!(), + "beam-weaver-provider-capture-#{System.unique_integer([:positive])}.json" + ) + + on_exit(fn -> File.rm(path) end) + + fixture = %{ + "provider" => "deepseek_responses", + "scenario" => "sanitization", + "request" => %{}, + "response" => %{ + "status" => 200, + "body" => + "event: response.created\ndata: {\"type\":\"response.created\",\"created_at\":1234567890,\"response\":{\"id\":\"response-live-id\"}}\n\n" + } + } + + File.write!(path, BeamWeaver.JSON.encode!(fixture)) + Fixtures.put_expected!(path, %{"id" => "response-live-id"}) + + sanitized = Fixtures.load_path!(path) + assert sanitized["expected"]["id"] == "fixture-id-1" + assert sanitized["response"]["body"] =~ ~s("created_at":"**REDACTED**") + assert sanitized["response"]["body"] =~ ~s("id":"fixture-id-1") + refute sanitized["response"]["body"] =~ "1234567890" + refute sanitized["response"]["body"] =~ "response-live-id" + end + end + describe "structured-output strategy policy fixtures" do test "fallback fixture records provider profile decision when native output is unsafe" do fixture = Fixtures.load!(:xai, "structured_fallback_when_tools_active") diff --git a/test/beam_weaver/retrieval/indexing_test.exs b/test/beam_weaver/retrieval/indexing_test.exs index 0a6f42d..34917a6 100644 --- a/test/beam_weaver/retrieval/indexing_test.exs +++ b/test/beam_weaver/retrieval/indexing_test.exs @@ -6,6 +6,7 @@ defmodule BeamWeaver.IndexingTest do alias BeamWeaver.Indexing alias BeamWeaver.Indexing.DocumentIndex, as: ReadWriteIndex alias BeamWeaver.Indexing.DocumentIndex.Memory, as: MemoryDocumentIndex + alias BeamWeaver.Indexing.Record alias BeamWeaver.Indexing.RecordManager alias BeamWeaver.Indexing.RecordManager.ETS, as: RecordETS alias BeamWeaver.Models.FakeEmbeddingModel @@ -414,14 +415,11 @@ defmodule BeamWeaver.IndexingTest do # Upstream reference: records = RecordETS.new(namespace: :records) - start = RecordManager.get_time(records) assert :ok = RecordManager.update(records, ["a"], group_ids: ["s1"]) - Process.sleep(1) assert :ok = RecordManager.update(records, ["b"], group_ids: ["s2"]) assert [true, false, true] = RecordManager.exists(records, ["a", "missing", "b"]) assert ["a"] = RecordManager.list_keys(records, group_ids: ["s1"]) - assert ["a"] = RecordManager.list_keys(records, after: start, limit: 1) assert {:error, %{type: :invalid_record_manager_update}} = RecordManager.update(records, ["a"], group_ids: ["s1", "extra"]) @@ -437,6 +435,32 @@ defmodule BeamWeaver.IndexingTest do assert [false, true, false] = RecordManager.exists(records, ["a", "b", "c"]) end + test "record manager key listing has a deterministic id tie-break" do + records = RecordETS.new(namespace: :records) + updated_at = DateTime.utc_now() + + assert :ok = + RecordManager.put(records, %Record{ + id: "b", + source_id: nil, + hash: "b", + updated_at: updated_at + }) + + assert :ok = + RecordManager.put(records, %Record{ + id: "a", + source_id: nil, + hash: "a", + updated_at: updated_at + }) + + assert ["a", "b"] = RecordManager.list_keys(records) + + after_time = DateTime.to_unix(updated_at, :microsecond) / 1_000_000 - 1 + assert ["a"] = RecordManager.list_keys(records, after: after_time, limit: 1) + end + test "memory document index supports standard upsert get delete retrieve and async helpers" do index = MemoryDocumentIndex.new(top_k: 1) diff --git a/test/beam_weaver/tools/shell/host_executor_test.exs b/test/beam_weaver/tools/shell/host_executor_test.exs index f1bdd9a..a34309a 100644 --- a/test/beam_weaver/tools/shell/host_executor_test.exs +++ b/test/beam_weaver/tools/shell/host_executor_test.exs @@ -1,5 +1,7 @@ defmodule BeamWeaver.Tools.Shell.HostExecutorTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false + + import ExUnit.CaptureIO alias BeamWeaver.ShellPolicy alias BeamWeaver.Tools.Shell.HostExecutor @@ -18,21 +20,54 @@ defmodule BeamWeaver.Tools.Shell.HostExecutorTest do assert result.output =~ "ok" end - test "does not leak stderr temp files when a separate-stderr command times out" do - policy = ShellPolicy.new!(allow: ["sleep"], stderr: :separate, timeout: 50) + test "closes command stdin like System.cmd" do + policy = ShellPolicy.new!(allow: ["cat"], timeout: 1_000) + + assert {:ok, %{status: 0, output: ""}} = HostExecutor.run("cat", policy) + end - before = stderr_temp_files() + test "separate stderr preserves shell syntax failures and nonzero status" do + policy = ShellPolicy.new!(allow: [~r/.*/], stderr: :separate, timeout: 1_000) - assert {:error, %{type: :shell_timeout}} = HostExecutor.run("sleep 5", policy) + assert {:ok, %{status: status, output: "", stderr: stderr}} = + HostExecutor.run("(", policy) + + assert status > 0 + assert stderr != "" + end - Process.sleep(100) + test "zero-timeout separate-stderr commands emit no shell errors or scratch leaks" do + policy = ShellPolicy.new!(allow: ["sleep"], stderr: :separate, timeout: 0) + + before = scratch_dirs() + + captured = + capture_io(:stderr, fn -> + for iteration <- 1..25 do + assert {:error, %{type: :shell_timeout}} = + HostExecutor.run("sleep 0.01", policy, command_id: "host-timeout-#{iteration}") + end + end) + + assert captured == "" + assert scratch_dirs() == before + assert port_messages(self()) == [] + end - assert stderr_temp_files() == before + defp port_messages(pid) do + pid + |> Process.info(:messages) + |> elem(1) + |> Enum.filter(fn + {port, _message} when is_port(port) -> true + {:EXIT, port, _reason} when is_port(port) -> true + _message -> false + end) end - defp stderr_temp_files do + defp scratch_dirs do System.tmp_dir!() - |> Path.join("beam_weaver_shell_stderr_*") + |> Path.join("beam_weaver_shell_scratch_*") |> Path.wildcard() |> MapSet.new() end diff --git a/test/beam_weaver/tools/shell/session_test.exs b/test/beam_weaver/tools/shell/session_test.exs index e6b4bea..87e48d3 100644 --- a/test/beam_weaver/tools/shell/session_test.exs +++ b/test/beam_weaver/tools/shell/session_test.exs @@ -1,24 +1,60 @@ defmodule BeamWeaver.Tools.Shell.SessionTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false + + import ExUnit.CaptureIO alias BeamWeaver.Tools.Shell.Session - test "does not leak metadata, env, or stderr temp files when a command times out" do + test "zero-timeout commands do not leak scratch or emit shell errors before delayed trailers" do + probe_root = + Path.join( + System.tmp_dir!(), + "beam_weaver_shell_timeout_probe_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(probe_root) + on_exit(fn -> File.rm_rf(probe_root) end) + + completed_path = Path.join(probe_root, "completed") + + delayed_write = + "sleep 0.05; printf late > \"$1/metadata\"; " <> + "printf done > #{shell_quote(completed_path)}" + + scratch_glob = shell_quote(System.tmp_dir!()) <> "/beam_weaver_shell_scratch_*" + + command = + "set -- #{scratch_glob}; scratch_dir=$1; " <> + "nohup sh -c #{shell_quote(delayed_write)} sh \"$scratch_dir\" >/dev/null 2>&1 & " <> + "sleep 0.2" + {:ok, pid} = - Session.start(policy: [allow: ["sleep"], stderr: :separate, timeout: 50]) + Session.start(policy: [allow: [~r/.*/], stderr: :separate, timeout: 0]) - before = session_temp_files() + before = scratch_dirs() - assert {:error, %{type: :shell_timeout, details: %{metadata: metadata}}} = - Session.execute(pid, "sleep 5", timeout: 50, command_id: "cmd-timeout") + captured = + capture_io(:stderr, fn -> + assert {:error, %{type: :shell_timeout, details: %{metadata: metadata}}} = + Session.execute( + pid, + command, + timeout: 0, + command_id: "cmd-timeout" + ) - assert metadata.command_id == "cmd-timeout" - assert metadata.kill_attempted == true - assert metadata.error == "timeout" + assert metadata.command_id == "cmd-timeout" + assert metadata.kill_attempted == true + assert metadata.error == "timeout" + end) - Process.sleep(100) + assert captured == "" + assert scratch_dirs() == before + assert port_messages(pid) == [] - assert session_temp_files() == before + assert eventually(fn -> File.read(completed_path) == {:ok, "done"} end) + assert scratch_dirs() == before + assert port_messages(pid) == [] Session.shutdown(pid) end @@ -37,13 +73,45 @@ defmodule BeamWeaver.Tools.Shell.SessionTest do Session.shutdown(pid) end - defp session_temp_files do - ["metadata", "env", "stderr"] - |> Enum.flat_map(fn label -> - System.tmp_dir!() - |> Path.join("beam_weaver_shell_#{label}_*") - |> Path.wildcard() - end) + defp scratch_dirs do + System.tmp_dir!() + |> Path.join("beam_weaver_shell_scratch_*") + |> Path.wildcard() |> MapSet.new() end + + defp port_messages(pid) do + pid + |> Process.info(:messages) + |> elem(1) + |> Enum.filter(fn + {port, _message} when is_port(port) -> true + {:EXIT, port, _reason} when is_port(port) -> true + _message -> false + end) + end + + defp eventually(predicate, timeout \\ 1_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_eventually(predicate, deadline) + end + + defp do_eventually(predicate, deadline) do + if predicate.() do + true + else + if System.monotonic_time(:millisecond) >= deadline do + false + else + receive do + after + 5 -> do_eventually(predicate, deadline) + end + end + end + end + + defp shell_quote(value) do + "'" <> String.replace(value, "'", "'\"'\"'") <> "'" + end end diff --git a/test/beam_weaver/transport/req_finch_test.exs b/test/beam_weaver/transport/req_finch_test.exs index f9745bf..583a8ce 100644 --- a/test/beam_weaver/transport/req_finch_test.exs +++ b/test/beam_weaver/transport/req_finch_test.exs @@ -1,11 +1,35 @@ defmodule BeamWeaver.Transport.ReqFinchTest do use ExUnit.Case + import ExUnit.CaptureIO + alias BeamWeaver.Transport.Error alias BeamWeaver.Transport.ReqFinch alias BeamWeaver.Transport.Request alias BeamWeaver.Transport.Response + test "uses Req 0.7 Finch option shapes" do + request = Request.new(method: :get, url: "https://example.test") + + assert ReqFinch.req_options(request)[:finch] == [name: BeamWeaver.Transport.Finch] + assert ReqFinch.req_options(request, finch: CustomFinch)[:finch] == [name: CustomFinch] + + assert ReqFinch.req_options(request, finch: [name: CustomFinch, pool_tag: :bulk])[:finch] == + [name: CustomFinch, pool_tag: :bulk] + + assert ReqFinch.req_options(request, finch: nil)[:finch] == nil + end + + test "live requests do not emit Req deprecation warnings" do + url = start_http_server("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok") + request = Request.new(method: :get, url: url) + + assert capture_io(:stderr, fn -> + assert {:ok, %Response{status: 200, body: "ok"}} = + ReqFinch.request(request, timeout: 1_000) + end) == "" + end + test "preserves live HTTP responses for successful and non-2xx statuses" do cases = [ {"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok", 200, "ok"}, diff --git a/test/fixtures/provider_conformance/deepseek/basic_chat.json b/test/fixtures/provider_conformance/deepseek/basic_chat.json new file mode 100644 index 0000000..5d0f137 --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/basic_chat.json @@ -0,0 +1,191 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "stop", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "reasoning_content": "We need to reply exactly \"pong\". The user said \"Reply with exactly: pong\". So just output pong.", + "token_usage": { + "completion_tokens": 29, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 89, + "prompt_tokens": 89, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 118 + }, + "usage": { + "completion_tokens": 29, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 89, + "prompt_tokens": 89, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 118 + } + }, + "role": "assistant", + "status": "stop", + "text": "pong", + "tool_calls": [], + "usage_metadata": { + "input_cost": 1.2460000000000001e-5, + "input_cost_details": { + "cache_read": 0.0, + "uncached": 1.2460000000000001e-5 + }, + "input_token_details": { + "cache_miss": 89, + "cache_read": 0 + }, + "input_tokens": 89, + "output_cost": 8.12e-6, + "output_cost_details": { + "text": 8.12e-6 + }, + "output_token_details": { + "reasoning": 26 + }, + "output_tokens": 29, + "total_cost": 2.0580000000000003e-5, + "total_tokens": 118 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "messages": [ + { + "content": "Reply with exactly: pong", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "stream": false + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": "nil", + "message": { + "content": "pong", + "reasoning_content": "We need to reply exactly \"pong\". The user said \"Reply with exactly: pong\". So just output pong.", + "role": "assistant" + } + } + ], + "created": "**REDACTED**", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "object": "chat.completion", + "system_fingerprint": "fixture-id-2", + "usage": { + "completion_tokens": 29, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 89, + "prompt_tokens": 89, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 118 + } + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "basic_chat" +} diff --git a/test/fixtures/provider_conformance/deepseek/provider_structured_success.json b/test/fixtures/provider_conformance/deepseek/provider_structured_success.json new file mode 100644 index 0000000..5a32d5b --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/provider_structured_success.json @@ -0,0 +1,201 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "stop", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "parsed": { + "answer": "pong" + }, + "provider": "deepseek", + "reasoning_content": "We need to output JSON with key \"answer\" and value \"pong\". The instruction says \"Return a structured answer where answer is exactly pong.\" So the JSON should be {\"answer\": \"pong\"}. Also we must output only JSON, no other text.", + "token_usage": { + "completion_tokens": 63, + "completion_tokens_details": { + "reasoning_tokens": 55 + }, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 124, + "prompt_tokens": 252, + "prompt_tokens_details": { + "cached_tokens": 128 + }, + "total_tokens": 315 + }, + "usage": { + "completion_tokens": 63, + "completion_tokens_details": { + "reasoning_tokens": 55 + }, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 124, + "prompt_tokens": 252, + "prompt_tokens_details": { + "cached_tokens": 128 + }, + "total_tokens": 315 + } + }, + "role": "assistant", + "status": "stop", + "text": "{\"answer\": \"pong\"}", + "tool_calls": [], + "usage_metadata": { + "input_cost": 1.7718400000000003e-5, + "input_cost_details": { + "cache_read": 3.584e-7, + "uncached": 1.7360000000000003e-5 + }, + "input_token_details": { + "cache_miss": 124, + "cache_read": 128 + }, + "input_tokens": 252, + "output_cost": 1.764e-5, + "output_cost_details": { + "text": 1.764e-5 + }, + "output_token_details": { + "reasoning": 55 + }, + "output_tokens": 63, + "total_cost": 3.5358400000000004e-5, + "total_tokens": 315 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "messages": [ + { + "content": "BeamWeaver structured output contract:\n- Return exactly one JSON object and no markdown, prose, code fences, or commentary.\n- Strictly validate against this schema.\n- Include every required key. Required keys: answer.\n- If information is unavailable, still include the key with a value compatible with the schema.\n\nSchema name: answer_output\nJSON Schema:\n{\n \"additionalProperties\": false,\n \"description\": \"A short answer.\",\n \"properties\": {\n \"answer\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"answer\"\n ],\n \"title\": \"answer_output\",\n \"type\": \"object\"\n}", + "role": "system" + }, + { + "content": "Return a structured answer where answer is exactly pong.", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "response_format": { + "type": "json_object" + }, + "stream": false + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": "nil", + "message": { + "content": "{\n \"answer\": \"pong\"\n}", + "reasoning_content": "We need to output JSON with key \"answer\" and value \"pong\". The instruction says \"Return a structured answer where answer is exactly pong.\" So the JSON should be {\"answer\": \"pong\"}. Also we must output only JSON, no other text.", + "role": "assistant" + } + } + ], + "created": "**REDACTED**", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "object": "chat.completion", + "system_fingerprint": "fixture-id-2", + "usage": { + "completion_tokens": 63, + "completion_tokens_details": { + "reasoning_tokens": 55 + }, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 124, + "prompt_tokens": 252, + "prompt_tokens_details": { + "cached_tokens": 128 + }, + "total_tokens": 315 + } + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "provider_structured_success" +} diff --git a/test/fixtures/provider_conformance/deepseek/reasoning_stream_truncated.json b/test/fixtures/provider_conformance/deepseek/reasoning_stream_truncated.json new file mode 100644 index 0000000..28277fd --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/reasoning_stream_truncated.json @@ -0,0 +1,172 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "length", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "reasoning_content": "We need answer user asks \"Think briefly about 12 * 11, then answer.\" Need likely compute 12*11 = 132. Need maybe mention", + "token_usage": { + "completion_tokens": 32, + "completion_tokens_details": { + "reasoning_tokens": 32 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 16, + "prompt_tokens": 16, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 48 + }, + "usage": { + "completion_tokens": 32, + "completion_tokens_details": { + "reasoning_tokens": 32 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 16, + "prompt_tokens": 16, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 48 + } + }, + "role": "assistant", + "status": "length", + "text": "", + "tool_calls": [], + "usage_metadata": { + "input_cost": 2.24e-6, + "input_cost_details": { + "cache_read": 0.0, + "uncached": 2.24e-6 + }, + "input_token_details": { + "cache_miss": 16, + "cache_read": 0 + }, + "input_tokens": 16, + "output_cost": 8.96e-6, + "output_cost_details": { + "text": 8.96e-6 + }, + "output_token_details": { + "reasoning": 32 + }, + "output_tokens": 32, + "total_cost": 1.1200000000000001e-5, + "total_tokens": 48 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "max_tokens": 32, + "messages": [ + { + "content": "Think briefly about 12 * 11, then answer.", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "reasoning_effort": "low", + "stream": true, + "stream_options": { + "include_usage": true + }, + "thinking": { + "type": "enabled" + } + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": "data: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"We\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" answer\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" asks\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"Think\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" briefly\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" about\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"12\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" *\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"11\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\",\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" then\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" answer\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" Need\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" likely\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" compute\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"12\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"*\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"11\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" =\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"132\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" Need\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" maybe\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" mention\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"finish_reason\":\"length\",\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":{\"completion_tokens\":32,\"completion_tokens_details\":{\"reasoning_tokens\":32},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":16,\"prompt_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"total_tokens\":48}}\n\ndata: [DONE]\n\n", + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "cache-control", + "no-cache" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "text/event-stream; charset=utf-8" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "reasoning_stream_truncated" +} diff --git a/test/fixtures/provider_conformance/deepseek/single_tool_call.json b/test/fixtures/provider_conformance/deepseek/single_tool_call.json new file mode 100644 index 0000000..d1e72af --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/single_tool_call.json @@ -0,0 +1,233 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "tool_calls", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "reasoning_content": "The user wants me to call get_weather for Tokyo. Do not answer directly, meaning I should just make the tool call.", + "token_usage": { + "completion_tokens": 71, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 256, + "prompt_cache_miss_tokens": 114, + "prompt_tokens": 370, + "prompt_tokens_details": { + "cached_tokens": 256 + }, + "total_tokens": 441 + }, + "usage": { + "completion_tokens": 71, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 256, + "prompt_cache_miss_tokens": 114, + "prompt_tokens": 370, + "prompt_tokens_details": { + "cached_tokens": 256 + }, + "total_tokens": 441 + } + }, + "role": "assistant", + "status": "tool_calls", + "text": "", + "tool_calls": [ + { + "args": { + "city": "Tokyo" + }, + "call_id": "fixture-id-2", + "id": "fixture-id-2", + "name": "get_weather", + "provider_id": "fixture-id-2" + } + ], + "usage_metadata": { + "input_cost": 1.66768e-5, + "input_cost_details": { + "cache_read": 7.168e-7, + "uncached": 1.596e-5 + }, + "input_token_details": { + "cache_miss": 114, + "cache_read": 256 + }, + "input_tokens": 370, + "output_cost": 1.9880000000000003e-5, + "output_cost_details": { + "text": 1.9880000000000003e-5 + }, + "output_token_details": { + "reasoning": 26 + }, + "output_tokens": 71, + "total_cost": 3.65568e-5, + "total_tokens": 441 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "messages": [ + { + "content": "Call get_weather for Tokyo. Do not answer directly.", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "stream": false, + "tools": [ + { + "function": { + "description": "Return current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + }, + "type": "function" + } + ] + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": { + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "logprobs": "nil", + "message": { + "content": "", + "reasoning_content": "The user wants me to call get_weather for Tokyo. Do not answer directly, meaning I should just make the tool call.", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\n \"city\": \"Tokyo\"\n}", + "name": "get_weather" + }, + "id": "fixture-id-2", + "index": 0, + "type": "function" + } + ] + } + } + ], + "created": "**REDACTED**", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "object": "chat.completion", + "system_fingerprint": "fixture-id-3", + "usage": { + "completion_tokens": 71, + "completion_tokens_details": { + "reasoning_tokens": 26 + }, + "prompt_cache_hit_tokens": 256, + "prompt_cache_miss_tokens": 114, + "prompt_tokens": 370, + "prompt_tokens_details": { + "cached_tokens": 256 + }, + "total_tokens": 441 + } + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "single_tool_call" +} diff --git a/test/fixtures/provider_conformance/deepseek/streaming_tool_call.json b/test/fixtures/provider_conformance/deepseek/streaming_tool_call.json new file mode 100644 index 0000000..f55ddea --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/streaming_tool_call.json @@ -0,0 +1,195 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "tool_calls", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "token_usage": { + "completion_tokens": 44, + "prompt_cache_hit_tokens": 256, + "prompt_cache_miss_tokens": 36, + "prompt_tokens": 292, + "prompt_tokens_details": { + "cached_tokens": 256 + }, + "total_tokens": 336 + }, + "usage": { + "completion_tokens": 44, + "prompt_cache_hit_tokens": 256, + "prompt_cache_miss_tokens": 36, + "prompt_tokens": 292, + "prompt_tokens_details": { + "cached_tokens": 256 + }, + "total_tokens": 336 + } + }, + "role": "assistant", + "status": "tool_calls", + "text": "", + "tool_calls": [ + { + "args": { + "city": "Tokyo" + }, + "call_id": "fixture-id-2", + "id": "fixture-id-2", + "name": "get_weather", + "provider_id": "fixture-id-2" + } + ], + "usage_metadata": { + "input_cost": 5.7568000000000005e-6, + "input_cost_details": { + "cache_read": 7.168e-7, + "uncached": 5.040000000000001e-6 + }, + "input_token_details": { + "cache_miss": 36, + "cache_read": 256 + }, + "input_tokens": 292, + "output_cost": 1.232e-5, + "output_cost_details": { + "text": 1.232e-5 + }, + "output_token_details": { + "reasoning": "nil" + }, + "output_tokens": 44, + "total_cost": 1.8076800000000003e-5, + "total_tokens": 336 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "messages": [ + { + "content": "Call get_weather for Tokyo. Do not answer directly.", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "stream": true, + "stream_options": { + "include_usage": true + }, + "thinking": { + "type": "disabled" + }, + "tool_choice": "auto", + "tools": [ + { + "function": { + "description": "Return current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + } + }, + "type": "function" + } + ] + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\",\"name\":\"get_weather\"},\"id\":\"fixture-id-2\",\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"city\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\": \"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"Tokyo\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"}\"},\"index\":0}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"\"},\"finish_reason\":\"tool_calls\",\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-3\",\"usage\":{\"completion_tokens\":44,\"prompt_cache_hit_tokens\":256,\"prompt_cache_miss_tokens\":36,\"prompt_tokens\":292,\"prompt_tokens_details\":{\"cached_tokens\":256},\"total_tokens\":336}}\n\ndata: [DONE]\n\n", + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "cache-control", + "no-cache" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "text/event-stream; charset=utf-8" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "streaming_tool_call" +} diff --git a/test/fixtures/provider_conformance/deepseek/streaming_usage.json b/test/fixtures/provider_conformance/deepseek/streaming_usage.json new file mode 100644 index 0000000..23adb28 --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek/streaming_usage.json @@ -0,0 +1,167 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "finish_reason": "stop", + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "reasoning_content": "We need to respond with exactly \"streamed pong\". The user's instruction: \"Reply with exactly: streamed pong\" So the output should be exactly that phrase. No extra text.", + "token_usage": { + "completion_tokens": 45, + "completion_tokens_details": { + "reasoning_tokens": 40 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 91, + "prompt_tokens": 91, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 136 + }, + "usage": { + "completion_tokens": 45, + "completion_tokens_details": { + "reasoning_tokens": 40 + }, + "prompt_cache_hit_tokens": 0, + "prompt_cache_miss_tokens": 91, + "prompt_tokens": 91, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "total_tokens": 136 + } + }, + "role": "assistant", + "status": "stop", + "text": "streamed pong", + "tool_calls": [], + "usage_metadata": { + "input_cost": 1.2740000000000002e-5, + "input_cost_details": { + "cache_read": 0.0, + "uncached": 1.2740000000000002e-5 + }, + "input_token_details": { + "cache_miss": 91, + "cache_read": 0 + }, + "input_tokens": 91, + "output_cost": 1.2600000000000001e-5, + "output_cost_details": { + "text": 1.2600000000000001e-5 + }, + "output_token_details": { + "reasoning": 40 + }, + "output_tokens": 45, + "total_cost": 2.5340000000000005e-5, + "total_tokens": 136 + } + } + }, + "provider": "deepseek", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "messages": [ + { + "content": "Reply with exactly: streamed pong", + "role": "user" + } + ], + "model": "deepseek-v4-flash", + "stream": true, + "stream_options": { + "include_usage": true + } + }, + "method": "POST", + "url": "https://api.deepseek.com/chat/completions" + }, + "response": { + "body": "data: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"We\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"stream\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"ed\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" p\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"ong\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"\\\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" The\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"'s\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" instruction\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"Reply\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" stream\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"ed\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" p\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"ong\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" So\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" the\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" output\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" should\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" be\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" that\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" phrase\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" No\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" extra\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\" text\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"stream\",\"reasoning_content\":null},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"ed\",\"reasoning_content\":null},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" p\",\"reasoning_content\":null},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"ong\",\"reasoning_content\":null},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":null}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":\"**REDACTED**\",\"id\":\"fixture-id-1\",\"model\":\"deepseek-v4-flash\",\"object\":\"chat.completion.chunk\",\"system_fingerprint\":\"fixture-id-2\",\"usage\":{\"completion_tokens\":45,\"completion_tokens_details\":{\"reasoning_tokens\":40},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":91,\"prompt_tokens\":91,\"prompt_tokens_details\":{\"cached_tokens\":0},\"total_tokens\":136}}\n\ndata: [DONE]\n\n", + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "cache-control", + "no-cache" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "text/event-stream; charset=utf-8" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "streaming_usage" +} diff --git a/test/fixtures/provider_conformance/deepseek_responses/basic_chat.json b/test/fixtures/provider_conformance/deepseek_responses/basic_chat.json new file mode 100644 index 0000000..a51cd78 --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek_responses/basic_chat.json @@ -0,0 +1,228 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "service_tier": "default", + "status": "completed", + "usage": { + "input_tokens": 89, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 29, + "output_tokens_details": { + "reasoning_tokens": 26 + }, + "total_tokens": 118 + } + }, + "role": "assistant", + "status": "completed", + "text": "pong", + "tool_calls": [], + "usage_metadata": { + "input_cost": 1.2460000000000001e-5, + "input_cost_details": { + "cache_read": 0.0, + "uncached": 1.2460000000000001e-5 + }, + "input_token_details": { + "cache_miss": 89, + "cache_read": 0 + }, + "input_tokens": 89, + "output_cost": 8.12e-6, + "output_cost_details": { + "text": 8.12e-6 + }, + "output_token_details": { + "reasoning": 26 + }, + "output_tokens": 29, + "total_cost": 2.0580000000000003e-5, + "total_tokens": 118 + } + } + }, + "provider": "deepseek_responses", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "input": [ + { + "content": "Reply with exactly: pong", + "role": "user", + "type": "message" + } + ], + "model": "deepseek-v4-flash", + "stream": false + }, + "method": "POST", + "url": "https://api.deepseek.com/responses" + }, + "response": { + "body": { + "background": false, + "completed_at": "**REDACTED**", + "content_filters": "nil", + "created_at": "**REDACTED**", + "error": "nil", + "frequency_penalty": 0.0, + "id": "fixture-id-1", + "incomplete_details": "nil", + "instructions": "nil", + "max_output_tokens": "nil", + "max_tool_calls": "nil", + "metadata": {}, + "model": "deepseek-v4-flash", + "moderation": "nil", + "object": "response", + "output": [ + { + "content": [ + { + "text": "We need to reply with exactly \"pong\". The user said \"Reply with exactly: pong\". So output just that.", + "type": "reasoning_text" + } + ], + "id": "fixture-id-2", + "status": "completed", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "pong", + "type": "output_text" + } + ], + "id": "fixture-id-3", + "phase": "final_answer", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": "nil", + "prompt_cache_key": "nil", + "prompt_cache_retention": "nil", + "reasoning": { + "effort": "nil", + "summary": "nil" + }, + "safety_identifier": "nil", + "service_tier": "default", + "status": "completed", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "nil" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 89, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 29, + "output_tokens_details": { + "reasoning_tokens": 26 + }, + "total_tokens": 118 + }, + "user": "nil" + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "basic_chat" +} diff --git a/test/fixtures/provider_conformance/deepseek_responses/provider_structured_success.json b/test/fixtures/provider_conformance/deepseek_responses/provider_structured_success.json new file mode 100644 index 0000000..77b251b --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek_responses/provider_structured_success.json @@ -0,0 +1,269 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "parsed": { + "answer": "pong" + }, + "provider": "deepseek", + "service_tier": "default", + "status": "completed", + "usage": { + "input_tokens": 155, + "input_tokens_details": { + "cached_tokens": 128 + }, + "output_tokens": 106, + "output_tokens_details": { + "reasoning_tokens": 98 + }, + "total_tokens": 261 + } + }, + "role": "assistant", + "status": "completed", + "text": "{\"answer\": \"pong\"}", + "tool_calls": [], + "usage_metadata": { + "input_cost": 4.1384e-6, + "input_cost_details": { + "cache_read": 3.584e-7, + "uncached": 3.7800000000000002e-6 + }, + "input_token_details": { + "cache_miss": 27, + "cache_read": 128 + }, + "input_tokens": 155, + "output_cost": 2.9680000000000004e-5, + "output_cost_details": { + "text": 2.9680000000000004e-5 + }, + "output_token_details": { + "reasoning": 98 + }, + "output_tokens": 106, + "total_cost": 3.38184e-5, + "total_tokens": 261 + } + } + }, + "provider": "deepseek_responses", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "input": [ + { + "content": "Return a structured answer where answer is exactly pong.", + "role": "user", + "type": "message" + } + ], + "model": "deepseek-v4-flash", + "stream": false, + "text": { + "format": { + "name": "answer_output", + "schema": { + "additionalProperties": false, + "description": "A short answer.", + "properties": { + "answer": { + "type": "string" + } + }, + "required": [ + "answer" + ], + "title": "answer_output", + "type": "object" + }, + "strict": true, + "type": "json_schema" + } + } + }, + "method": "POST", + "url": "https://api.deepseek.com/responses" + }, + "response": { + "body": { + "background": false, + "completed_at": "**REDACTED**", + "content_filters": "nil", + "created_at": "**REDACTED**", + "error": "nil", + "frequency_penalty": 0.0, + "id": "fixture-id-1", + "incomplete_details": "nil", + "instructions": "nil", + "max_output_tokens": "nil", + "max_tool_calls": "nil", + "metadata": {}, + "model": "deepseek-v4-flash", + "moderation": "nil", + "object": "response", + "output": [ + { + "content": [ + { + "text": "We need to output JSON with schema: {\"additionalProperties\": false, \"properties\": {\"answer\": {\"type\": \"string\"}}, \"required\": [\"answer\"], \"type\": \"object\"}. The instruction: \"Return a structured answer where answer is exactly pong.\" So answer should be the string \"pong\". So JSON: {\"answer\": \"pong\"} but ensure it's valid. Also note \"additionalProperties\": false means no extra fields. So just produce that.", + "type": "reasoning_text" + } + ], + "id": "fixture-id-2", + "status": "completed", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "{\n \"answer\": \"pong\"\n}", + "type": "output_text" + } + ], + "id": "fixture-id-3", + "phase": "final_answer", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": "nil", + "prompt_cache_key": "nil", + "prompt_cache_retention": "nil", + "reasoning": { + "effort": "nil", + "summary": "nil" + }, + "safety_identifier": "nil", + "service_tier": "default", + "status": "completed", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "description": "nil", + "name": "answer_output", + "schema": { + "additionalProperties": false, + "description": "A short answer.", + "properties": { + "answer": { + "type": "string" + } + }, + "required": [ + "answer" + ], + "title": "answer_output", + "type": "object" + }, + "strict": true, + "type": "json_schema" + }, + "verbosity": "nil" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 155, + "input_tokens_details": { + "cached_tokens": 128 + }, + "output_tokens": 106, + "output_tokens_details": { + "reasoning_tokens": 98 + }, + "total_tokens": 261 + }, + "user": "nil" + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "provider_structured_success" +} diff --git a/test/fixtures/provider_conformance/deepseek_responses/single_tool_call.json b/test/fixtures/provider_conformance/deepseek_responses/single_tool_call.json new file mode 100644 index 0000000..0dcb95d --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek_responses/single_tool_call.json @@ -0,0 +1,269 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "service_tier": "default", + "status": "completed", + "usage": { + "input_tokens": 370, + "input_tokens_details": { + "cached_tokens": 256 + }, + "output_tokens": 69, + "output_tokens_details": { + "reasoning_tokens": 24 + }, + "total_tokens": 439 + } + }, + "role": "assistant", + "status": "completed", + "text": "", + "tool_calls": [ + { + "args": { + "city": "Tokyo" + }, + "call_id": "fixture-id-2", + "id": "fixture-id-2", + "name": "get_weather", + "provider_id": "fixture-id-3" + } + ], + "usage_metadata": { + "input_cost": 1.66768e-5, + "input_cost_details": { + "cache_read": 7.168e-7, + "uncached": 1.596e-5 + }, + "input_token_details": { + "cache_miss": 114, + "cache_read": 256 + }, + "input_tokens": 370, + "output_cost": 1.932e-5, + "output_cost_details": { + "text": 1.932e-5 + }, + "output_token_details": { + "reasoning": 24 + }, + "output_tokens": 69, + "total_cost": 3.59968e-5, + "total_tokens": 439 + } + } + }, + "provider": "deepseek_responses", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "input": [ + { + "content": "Call get_weather for Tokyo. Do not answer directly.", + "role": "user", + "type": "message" + } + ], + "model": "deepseek-v4-flash", + "stream": false, + "tools": [ + { + "description": "Return current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "type": "function" + } + ] + }, + "method": "POST", + "url": "https://api.deepseek.com/responses" + }, + "response": { + "body": { + "background": false, + "completed_at": "**REDACTED**", + "content_filters": "nil", + "created_at": "**REDACTED**", + "error": "nil", + "frequency_penalty": 0.0, + "id": "fixture-id-1", + "incomplete_details": "nil", + "instructions": "nil", + "max_output_tokens": "nil", + "max_tool_calls": "nil", + "metadata": {}, + "model": "deepseek-v4-flash", + "moderation": "nil", + "object": "response", + "output": [ + { + "content": [ + { + "text": "The user wants me to call get_weather for Tokyo and not answer directly. So I should make the tool call.", + "type": "reasoning_text" + } + ], + "id": "fixture-id-4", + "status": "completed", + "summary": [], + "type": "reasoning" + }, + { + "arguments": "{\n \"city\": \"Tokyo\"\n}", + "call_id": "fixture-id-2", + "id": "fixture-id-3", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": "nil", + "prompt_cache_key": "nil", + "prompt_cache_retention": "nil", + "reasoning": { + "effort": "nil", + "summary": "nil" + }, + "safety_identifier": "nil", + "service_tier": "default", + "status": "completed", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "nil" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Return current weather for a city.", + "name": "get_weather", + "parameters": { + "additionalProperties": false, + "properties": { + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "type": "object" + }, + "strict": "nil", + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 370, + "input_tokens_details": { + "cached_tokens": 256 + }, + "output_tokens": 69, + "output_tokens_details": { + "reasoning_tokens": 24 + }, + "total_tokens": 439 + }, + "user": "nil" + }, + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "application/json" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "single_tool_call" +} diff --git a/test/fixtures/provider_conformance/deepseek_responses/streaming_usage.json b/test/fixtures/provider_conformance/deepseek_responses/streaming_usage.json new file mode 100644 index 0000000..d886324 --- /dev/null +++ b/test/fixtures/provider_conformance/deepseek_responses/streaming_usage.json @@ -0,0 +1,150 @@ +{ + "expected": { + "message": { + "id": "fixture-id-1", + "metadata": { + "id": "fixture-id-1", + "model": "deepseek-v4-flash", + "model_name": "deepseek-v4-flash", + "model_provider": "deepseek", + "provider": "deepseek", + "service_tier": "default", + "status": "completed", + "usage": { + "input_tokens": 91, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 35, + "output_tokens_details": { + "reasoning_tokens": 30 + }, + "total_tokens": 126 + } + }, + "role": "assistant", + "status": "completed", + "text": "streamed pong", + "tool_calls": [], + "usage_metadata": { + "input_cost": 1.2740000000000002e-5, + "input_cost_details": { + "cache_read": 0.0, + "uncached": 1.2740000000000002e-5 + }, + "input_token_details": { + "cache_miss": 91, + "cache_read": 0 + }, + "input_tokens": 91, + "output_cost": 9.800000000000001e-6, + "output_cost_details": { + "text": 9.800000000000001e-6 + }, + "output_token_details": { + "reasoning": 30 + }, + "output_tokens": 35, + "total_cost": 2.2540000000000004e-5, + "total_tokens": 126 + } + } + }, + "provider": "deepseek_responses", + "request": { + "headers": [ + [ + "authorization", + "**REDACTED**" + ], + [ + "content-type", + "application/json" + ], + [ + "user-agent", + "beam_weaver-deepseek/0.1" + ] + ], + "json": { + "input": [ + { + "content": "Reply with exactly: streamed pong", + "role": "user", + "type": "message" + } + ], + "model": "deepseek-v4-flash", + "stream": true + }, + "method": "POST", + "url": "https://api.deepseek.com/responses" + }, + "response": { + "body": "event: response.created\ndata: {\"response\":{\"safety_identifier\":null,\"store\":false,\"error\":null,\"created_at\":\"**REDACTED**\",\"presence_penalty\":0.0,\"status\":\"in_progress\",\"max_tool_calls\":null,\"instructions\":null,\"prompt_cache_retention\":null,\"completed_at\":null,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":null},\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"frequency_penalty\":0.0,\"top_logprobs\":0,\"reasoning\":{\"effort\":null,\"summary\":null},\"usage\":null,\"incomplete_details\":null,\"model\":\"deepseek-v4-flash\",\"truncation\":\"disabled\",\"metadata\":{},\"service_tier\":\"default\",\"id\":\"fixture-id-1\",\"content_filters\":null,\"top_p\":1.0,\"user\":null,\"prompt_cache_key\":null,\"moderation\":null,\"object\":\"response\",\"background\":false,\"previous_response_id\":null,\"max_output_tokens\":null,\"output\":[],\"tools\":[]},\"sequence_number\":0,\"type\":\"response.created\"}\n\nevent: response.in_progress\ndata: {\"response\":{\"safety_identifier\":null,\"store\":false,\"error\":null,\"created_at\":\"**REDACTED**\",\"presence_penalty\":0.0,\"status\":\"in_progress\",\"max_tool_calls\":null,\"instructions\":null,\"prompt_cache_retention\":null,\"completed_at\":null,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":null},\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"frequency_penalty\":0.0,\"top_logprobs\":0,\"reasoning\":{\"effort\":null,\"summary\":null},\"usage\":null,\"incomplete_details\":null,\"model\":\"deepseek-v4-flash\",\"truncation\":\"disabled\",\"metadata\":{},\"service_tier\":\"default\",\"id\":\"fixture-id-1\",\"content_filters\":null,\"top_p\":1.0,\"user\":null,\"prompt_cache_key\":null,\"moderation\":null,\"object\":\"response\",\"background\":false,\"previous_response_id\":null,\"max_output_tokens\":null,\"output\":[],\"tools\":[]},\"sequence_number\":1,\"type\":\"response.in_progress\"}\n\nevent: response.output_item.added\ndata: {\"item\":{\"content\":[],\"id\":\"fixture-id-2\",\"status\":\"in_progress\",\"summary\":[],\"type\":\"reasoning\"},\"output_index\":0,\"sequence_number\":2,\"type\":\"response.output_item.added\"}\n\nevent: response.content_part.added\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-2\",\"output_index\":0,\"part\":{\"text\":\"\",\"type\":\"reasoning_text\"},\"sequence_number\":3,\"type\":\"response.content_part.added\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"We\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":4,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" need\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":5,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" to\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":6,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" reply\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":7,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" exactly\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":8,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" with\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":9,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" \\\"\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":10,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"stream\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":11,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"ed\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":12,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" p\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":13,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"ong\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":14,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"\\\".\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":15,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" The\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":16,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" user\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":17,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" asked\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":18,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" \\\"\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":19,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"Reply\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":20,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" with\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":21,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" exactly\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":22,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\":\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":23,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" stream\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":24,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"ed\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":25,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" p\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":26,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"ong\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":27,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\"\\\".\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":28,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" So\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":29,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" output\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":30,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" just\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":31,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\" that\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":32,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.delta\ndata: {\"content_index\":0,\"delta\":\".\",\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":33,\"type\":\"response.reasoning_text.delta\"}\n\nevent: response.reasoning_text.done\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-2\",\"output_index\":0,\"sequence_number\":34,\"text\":\"We need to reply exactly with \\\"streamed pong\\\". The user asked \\\"Reply with exactly: streamed pong\\\". So output just that.\",\"type\":\"response.reasoning_text.done\"}\n\nevent: response.content_part.done\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-2\",\"output_index\":0,\"part\":{\"text\":\"We need to reply exactly with \\\"streamed pong\\\". The user asked \\\"Reply with exactly: streamed pong\\\". So output just that.\",\"type\":\"reasoning_text\"},\"sequence_number\":35,\"type\":\"response.content_part.done\"}\n\nevent: response.output_item.done\ndata: {\"item\":{\"content\":[{\"text\":\"We need to reply exactly with \\\"streamed pong\\\". The user asked \\\"Reply with exactly: streamed pong\\\". So output just that.\",\"type\":\"reasoning_text\"}],\"id\":\"fixture-id-2\",\"status\":\"completed\",\"summary\":[],\"type\":\"reasoning\"},\"output_index\":0,\"sequence_number\":36,\"type\":\"response.output_item.done\"}\n\nevent: response.output_item.added\ndata: {\"item\":{\"content\":[],\"id\":\"fixture-id-3\",\"phase\":\"final_answer\",\"role\":\"assistant\",\"status\":\"in_progress\",\"type\":\"message\"},\"output_index\":1,\"sequence_number\":37,\"type\":\"response.output_item.added\"}\n\nevent: response.content_part.added\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-3\",\"output_index\":1,\"part\":{\"annotations\":[],\"logprobs\":[],\"text\":\"\",\"type\":\"output_text\"},\"sequence_number\":38,\"type\":\"response.content_part.added\"}\n\nevent: response.output_text.delta\ndata: {\"content_index\":0,\"delta\":\"stream\",\"item_id\":\"fixture-id-3\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":39,\"type\":\"response.output_text.delta\"}\n\nevent: response.output_text.delta\ndata: {\"content_index\":0,\"delta\":\"ed\",\"item_id\":\"fixture-id-3\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":40,\"type\":\"response.output_text.delta\"}\n\nevent: response.output_text.delta\ndata: {\"content_index\":0,\"delta\":\" p\",\"item_id\":\"fixture-id-3\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":41,\"type\":\"response.output_text.delta\"}\n\nevent: response.output_text.delta\ndata: {\"content_index\":0,\"delta\":\"ong\",\"item_id\":\"fixture-id-3\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":42,\"type\":\"response.output_text.delta\"}\n\nevent: response.output_text.done\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-3\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":43,\"text\":\"streamed pong\",\"type\":\"response.output_text.done\"}\n\nevent: response.content_part.done\ndata: {\"content_index\":0,\"item_id\":\"fixture-id-3\",\"output_index\":1,\"part\":{\"annotations\":[],\"logprobs\":[],\"text\":\"streamed pong\",\"type\":\"output_text\"},\"sequence_number\":44,\"type\":\"response.content_part.done\"}\n\nevent: response.output_item.done\ndata: {\"item\":{\"content\":[{\"annotations\":[],\"logprobs\":[],\"text\":\"streamed pong\",\"type\":\"output_text\"}],\"id\":\"fixture-id-3\",\"phase\":\"final_answer\",\"role\":\"assistant\",\"status\":\"completed\",\"type\":\"message\"},\"output_index\":1,\"sequence_number\":45,\"type\":\"response.output_item.done\"}\n\nevent: response.completed\ndata: {\"response\":{\"safety_identifier\":null,\"store\":false,\"error\":null,\"created_at\":\"**REDACTED**\",\"presence_penalty\":0.0,\"status\":\"completed\",\"max_tool_calls\":null,\"instructions\":null,\"prompt_cache_retention\":null,\"completed_at\":\"**REDACTED**\",\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":null},\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"frequency_penalty\":0.0,\"top_logprobs\":0,\"reasoning\":{\"effort\":null,\"summary\":null},\"usage\":{\"input_tokens\":91,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":35,\"output_tokens_details\":{\"reasoning_tokens\":30},\"total_tokens\":126},\"incomplete_details\":null,\"model\":\"deepseek-v4-flash\",\"truncation\":\"disabled\",\"metadata\":{},\"service_tier\":\"default\",\"id\":\"fixture-id-1\",\"content_filters\":null,\"top_p\":1.0,\"user\":null,\"prompt_cache_key\":null,\"moderation\":null,\"object\":\"response\",\"background\":false,\"previous_response_id\":null,\"max_output_tokens\":null,\"output\":[{\"content\":[{\"text\":\"We need to reply exactly with \\\"streamed pong\\\". The user asked \\\"Reply with exactly: streamed pong\\\". So output just that.\",\"type\":\"reasoning_text\"}],\"id\":\"fixture-id-2\",\"status\":\"completed\",\"summary\":[],\"type\":\"reasoning\"},{\"content\":[{\"annotations\":[],\"logprobs\":[],\"text\":\"streamed pong\",\"type\":\"output_text\"}],\"id\":\"fixture-id-3\",\"phase\":\"final_answer\",\"role\":\"assistant\",\"status\":\"completed\",\"type\":\"message\"}],\"tools\":[]},\"sequence_number\":46,\"type\":\"response.completed\"}\n\n", + "headers": [ + [ + "access-control-allow-credentials", + "**REDACTED**" + ], + [ + "cache-control", + "no-cache" + ], + [ + "connection", + "keep-alive" + ], + [ + "content-type", + "text/event-stream; charset=utf-8" + ], + [ + "date", + "**REDACTED**" + ], + [ + "server", + "**REDACTED**" + ], + [ + "strict-transport-security", + "max-age=31536000; includeSubDomains; preload" + ], + [ + "transfer-encoding", + "chunked" + ], + [ + "vary", + "origin, access-control-request-method, access-control-request-headers" + ], + [ + "via", + "**REDACTED**" + ], + [ + "x-amz-cf-id", + "**REDACTED**" + ], + [ + "x-amz-cf-pop", + "**REDACTED**" + ], + [ + "x-cache", + "**REDACTED**" + ], + [ + "x-content-type-options", + "nosniff" + ], + [ + "x-ds-trace-id", + "**REDACTED**" + ] + ], + "status": 200 + }, + "scenario": "streaming_usage" +}