Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/sandbox-acp-agent/cordis.yml # examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/session/README.md # packages/core/session/src/types.ts # packages/core/session/tests/derived-cache.spec.ts # packages/llm/llm-deepseek/src/index.ts # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/convert.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/call-config.ts # packages/llm/llm/src/index.ts # packages/ui/acp-agent/src/index.ts # packages/ui/acp/tests/harness.ts # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/src/server.ts # packages/ui/stdio-agent/README.md # packages/ui/stdio-agent/src/index.ts # python/sdk/README.i18n.yaml
This commit is contained in:
@@ -33,10 +33,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
|
||||
- **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).
|
||||
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
|
||||
|
||||
## Limitations (MVP, documented deliberately)
|
||||
|
||||
- `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.
|
||||
@@ -44,3 +40,23 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LI
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### DeepSeek request
|
||||
|
||||
**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted.
|
||||
|
||||
**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available.
|
||||
|
||||
### DeepSeek response
|
||||
|
||||
**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble.
|
||||
|
||||
**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
|
||||
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
|
||||
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
|
||||
|
||||
@@ -52,14 +52,8 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, this.options.defaults ?? {})
|
||||
|
||||
// TODO(http): deliberately raw `fetch` for the hand-rolled SSE body.
|
||||
// `@cordisjs/plugin-http` (ctx.http) would give proxy/intercept/timeout
|
||||
// uniformity AND can stream (`responseType: 'stream'` yields the same
|
||||
// ReadableStream<Uint8Array> parseSse consumes), but adopting it today
|
||||
// costs a hard `undici` dependency (it does `require('undici')` with no
|
||||
// globalThis.fetch fallback) plus an unconditional `@cordisjs/fetch-file`
|
||||
// import (pulling file-type + mime-types) for a file:// path we never hit.
|
||||
// Revisit when a second adapter wants shared proxy/intercept config.
|
||||
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
|
||||
// outweighs its additional runtime dependencies.
|
||||
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -79,15 +73,8 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
const parsed = await response.json() as WireError
|
||||
if (parsed.error?.message) message = parsed.error.message
|
||||
} catch {
|
||||
// Paranoid by design: `code` and the HTTP status are ALREADY captured
|
||||
// above (and passed to LlmError below), so the only thing this `try`
|
||||
// can add is a richer provider-supplied message. A malformed, empty,
|
||||
// or non-JSON error body is a normal thing for gateways/proxies to
|
||||
// return on a 5xx/429 — swallowing the parse failure keeps the usable
|
||||
// status-line message instead of letting a JSON.parse throw mask the
|
||||
// real HTTP error. Nothing else reaches this catch: response.json()
|
||||
// is the sole statement, and any non-parse failure (e.g. body already
|
||||
// consumed) is equally non-actionable here.
|
||||
// Only swallow error-body parsing: status and code are already captured,
|
||||
// so malformed gateway JSON must not mask the actionable HTTP failure.
|
||||
}
|
||||
throw new LlmError(message, code, response.status)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
/**
|
||||
* DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the
|
||||
* `deepseek` provider route on `ctx.llm`.
|
||||
*
|
||||
* Config is cordis-native (schemastery). Secrets flow per the repo policy:
|
||||
* `apiKey` from cordis.yml via the `!!js` tag (`!!js process.env.DEEPSEEK_API_KEY`)
|
||||
* or from the environment directly; never from ad-hoc files.
|
||||
*
|
||||
* ```yaml
|
||||
* - id: llm-deepseek
|
||||
* name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
* config:
|
||||
* apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
* baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
* ```
|
||||
*
|
||||
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses
|
||||
* Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`,
|
||||
* as shown in the package README, rather than reading ad hoc files.
|
||||
* @module @deepseek-ai/dsh-llm-deepseek
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
/**
|
||||
* Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the
|
||||
* DeepSeek chat-completions request body.
|
||||
*
|
||||
* Block-type mapping (core types handled explicitly; merge-extensible unions
|
||||
* mean plugin-added block types exist — they are skipped, never errors):
|
||||
*
|
||||
* - user `text` → string content (joined)
|
||||
* - assistant `text` → `content`; `reasoning` → `reasoning_content`, but
|
||||
* ONLY on assistant messages that carry tool calls (the official passback
|
||||
* rule for thinking mode — required there, ignored elsewhere, so we save
|
||||
* the tokens elsewhere); `tool-call` → `tool_calls[]`
|
||||
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
|
||||
*
|
||||
* Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text
|
||||
* becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages.
|
||||
* Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by
|
||||
* thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected.
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
/**
|
||||
* Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines;
|
||||
* CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal
|
||||
* `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}.
|
||||
*
|
||||
* Minimal SSE (text/event-stream) parser for the chat-completions stream.
|
||||
*
|
||||
* Yields each event's `data:` payload as a string, ending with the literal
|
||||
* `'[DONE]'` sentinel so the consumer owns end-of-stream flushing. A stream
|
||||
* that closes WITHOUT `[DONE]` is a protocol violation → `LlmError`.
|
||||
*
|
||||
* Handles the wire realities: payloads split across network reads at
|
||||
* arbitrary byte positions (including mid-UTF-8), CRLF line endings,
|
||||
* multi-`data:` events (joined with newlines per the SSE spec), comment
|
||||
* lines, and non-data fields (ignored).
|
||||
*
|
||||
* @module dsh-llm-deepseek/sse
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
/**
|
||||
* Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool
|
||||
* call index. An empty initial reasoning delta does not open a block. Finish reason and the latest
|
||||
* usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes
|
||||
* while ensuring no chunk follows `finish`.
|
||||
*
|
||||
* Translate DeepSeek wire chunks into the harness `StreamChunk` protocol.
|
||||
*
|
||||
* A small state machine over the SSE payload stream:
|
||||
* - `delta.content` / `delta.reasoning_content` / `delta.tool_calls[i]` each
|
||||
* own one harness block (index allocated on first sight). The first
|
||||
* thinking-mode chunk carries `reasoning_content: ""` — that must NOT open
|
||||
* a reasoning block.
|
||||
* - `finish_reason` and `usage` are DEFERRED: emitted only at the `[DONE]`
|
||||
* sentinel, so the wire's two usage shapes (attached to the finish chunk,
|
||||
* or a trailing usage-only chunk) both work and nothing ever follows
|
||||
* `finish`. Last usage wins.
|
||||
*
|
||||
* @module dsh-llm-deepseek/translate
|
||||
*/
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('serializeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: assistant content shapes', () => {
|
||||
describe('assistant empty and tool-call content shapes', () => {
|
||||
it('serializes a content-less, tool-call-less assistant message as null content', () => {
|
||||
// Aborted/empty assistant turns: no text, no calls → null (the wire
|
||||
// accepts it; "" is reserved for tool-call turns per the samples).
|
||||
|
||||
Reference in New Issue
Block a user