Add two DeepSeek LLM adapters: dsh-llm-deepseek and dsh-llm-pi-ai

The first real LlmAdapter implementations, shipped as a deliberate pair:
same models and wire protocol, completely different internals, so the
StreamChunk protocol is verified across independent implementations.

- dsh-llm-deepseek: hand-rolled fetch + SSE parser + chunk-translation
  state machine against the official chat-completions format (thinking
  mode via top-level thinking/reasoning_effort; the empty-string
  reasoning_content first chunk; usage attached to the finish chunk or
  trailing; reasoning_content passback on tool-call turns; disjoint
  cache-token accounting).
- dsh-llm-pi-ai: the same endpoint through @earendil-works/pi-ai,
  mapping its event vocabulary (parsed tool arguments, in-stream error
  events, folded reasoning tokens) onto the same chunks.

The agent loop now honors the in-band error path: an adapter that ends
its stream with finish {kind:error|aborted} (the only option for
adapters that can't throw mid-stream, like pi-ai) is translated into a
step error, so the turn ends error/aborted with a logged error event
instead of a normal completed assistant message. This makes the
StreamChunk error contract real for both adapters; docs/architecture.md
and the StreamChunk doc are updated accordingly.

New yarn test:e2e (vitest.e2e.config.ts, *.e2e.ts) runs key-gated
real-API matrices for both adapters across V4 Flash/Pro and all
thinking/effort levels; it self-skips without DEEPSEEK_API_KEY. Unit
suites run against local node:http mock SSE servers at 100% per-file
coverage.
This commit is contained in:
Tianyi Cui
2026-06-13 00:28:29 +08:00
parent 8b5a3ef730
commit ab19fed77c
38 changed files with 4567 additions and 28 deletions

View File

@@ -0,0 +1,83 @@
# @deepseek-ai/dsh-llm-deepseek
DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled
`fetch` + SSE translation from the official wire format (source of truth:
the API docs — guides/thinking_mode, guides/tool_calls,
api/create-chat-completion) into the `StreamChunk` protocol.
A second, independent implementation of the same seam exists in
`@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one
per context (registering both for the same model names throws by design).
## Config
```yaml
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
models: [deepseek-v4-flash, deepseek-v4-pro] # one adapter, registered for each name
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
```
`models` lists every model name this one adapter instance serves: the adapter
registers itself for each (the harness model name IS the wire `model` string),
so a `generate`/`stream` call routes to it whenever `options.model` is any of
them. Registering a second adapter for a name already taken throws
`LlmError('DUPLICATE_ADAPTER')` (the LLM service enforces one adapter per
model, all-or-nothing).
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort`
wire field is not sent and the server applies its own default for the model.
The only accepted values are `high` and `max` (DeepSeek's official effort
levels). It is meaningful only with thinking enabled (the provider default).
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as
the official top-level `thinking: {type}` / `reasoning_effort` wire fields.
They live in adapter config (not `GenerateOptions`) to keep the core
vocabulary provider-neutral.
## Wire-format notes (verified live + against the official docs)
- Streaming only (`stream_options.include_usage` always on). `usage` may
arrive attached to the finish chunk or as a trailing usage-only chunk —
the translator defers both to `[DONE]`, so `usage` always precedes
`finish` and nothing follows `finish`.
- The first thinking-mode chunk carries `reasoning_content: ""` — handled
(no spurious reasoning block).
- **Reasoning passback rule**: on assistant turns that carried tool calls,
`reasoning_content` is serialized back in history (required by the API in
thinking mode); on tool-call-free turns it is dropped (ignored anyway —
saves tokens).
- `strict` on tool schemas passes through (officially Beta; the public API
wants the `/beta` base URL for it, the internal endpoint accepts it
directly).
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` /
`prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write
metric.
## Limitations (MVP, documented deliberately)
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix
completion is a Beta feature on the `/beta` base URL; future work.
- `image` blocks are skipped (no vision support on these models).
- `tool_choice` is not mapped (not part of the core vocabulary).
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403),
`RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_<status>`
otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or
`MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s
(e.g. `content_filter`, `insufficient_system_resource`) become
`finish {kind: 'error', code: <REASON>}` chunks.
## Testing
Unit suites run against a local `node:http` mock SSE server (no network).
Real-API coverage lives in `tests/adapter.e2e.ts` (`yarn test:e2e`,
key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both
official effort levels, including the thinking+tools round trip with
reasoning passback.