Repository navigation
Replies: 3 comments
|
Independent report on a different platform (Debian/Linux, dsh Environment
What happened
Why the session never recoversThe incremental upload watermark only advances on HTTP 2xx ( Controlled reproduction against the real endpointSame credentials, minimal body, one variable changed:
Note the serialization path: Workaround we used
SuggestionAlongside sanitizing unpaired surrogates in Happy to test a patch against 0.2.1-rc.2 if that helps. |
|
Here is a tested fix and unit test for both parts of this issue (sanitizing the payload and preserving the raw provider error message). Cause and Tested Solution
Complete Tested Patchdiff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts
index df35b6ce3b..b47689086b 100644
--- a/packages/llm/llm-deepseek/src/adapter.ts
+++ b/packages/llm/llm-deepseek/src/adapter.ts
@@ -160,7 +160,7 @@ export class DeepSeekAdapter<C extends Connection = Connection> extends LlmAdapt
}
const detail = providerErrorDetail(raw)
if (await files.retry(detail)) continue
- const failure = providerError(raw, response.status, response.headers)
+ const failure = providerError(raw ?? text, response.status, response.headers)
const message = files.errorMessage(response.status, failure.message, detail)
throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) })
}
diff --git a/packages/llm/llm-deepseek/src/request-extensions.ts b/packages/llm/llm-deepseek/src/request-extensions.ts
index be6ca7c9c8..db0236a0fb 100644
--- a/packages/llm/llm-deepseek/src/request-extensions.ts
+++ b/packages/llm/llm-deepseek/src/request-extensions.ts
@@ -6,6 +6,9 @@ import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions }
import type { DeepSeekAdapterOptions } from './types.ts'
+const wellFormedJsonReplacer = (_key: string, value: unknown): unknown =>
+ typeof value === 'string' ? value.toWellFormed() : value
+
/**
* Merge contributions without replacing Messages fields. A rejected preparation or
@@ -39,10 +42,10 @@ export async function prepareRequestExtensions(
}
let payload: string
try {
- payload = JSON.stringify({ ...body, ...extensions.fields })
+ payload = JSON.stringify({ ...body, ...extensions.fields }, wellFormedJsonReplacer)
} catch (error) {
// A base request that cannot serialize throws here, before any field is reported as omitted.
- const base = JSON.stringify(body)
+ const base = JSON.stringify(body, wellFormedJsonReplacer)
onOmitted(fields, error)
return { payload: base, accept: () => Promise.resolve() }
}
diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts
index 935e8855df..70615611c0 100644
--- a/packages/llm/llm-deepseek/src/transport.ts
+++ b/packages/llm/llm-deepseek/src/transport.ts
@@ -22,7 +22,12 @@ export function providerErrorDetail(raw: unknown): string {
export function providerError(raw: unknown, status: number | undefined, headers?: Headers): LlmError {
const envelope = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
const error = typeof envelope.error === 'object' && envelope.error !== null ? envelope.error as Record<string, unknown> : {}
- const message = typeof error.message === 'string' ? error.message : `DeepSeek Messages request failed (${status ?? 'stream error'})`
+ const rawText = typeof raw === 'string' ? raw.trim() : ''
+ const message = typeof error.message === 'string'
+ ? error.message
+ : rawText.length > 0
+ ? `DeepSeek Messages request failed (${status ?? 'stream error'}): ${rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText}`
+ : `DeepSeek Messages request failed (${status ?? 'stream error'})`
const type = typeof error.type === 'string' ? error.type : ''
const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}`
let code: string
diff --git a/packages/llm/llm-deepseek/tests/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts
index 4b727a2ae5..625e890e7f 100644
--- a/packages/llm/llm-deepseek/tests/extensions.spec.ts
+++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts
@@ -195,4 +195,27 @@ describe('Messages request extensions', () => {
expect(accept).not.toHaveBeenCalled()
expect(warnings).toEqual([])
})
+
+ it('sanitizes lone surrogates in request extensions and base body to well-formed unicode', async () => {
+ const ctx = await boot()
+ const accept = vi.fn()
+ ctx.deepseekLlmApiExtensions.register('dsh_messages_test', {
+ prepare: () => ({ value: { value: 'lone \uD83D surrogate' }, accept }),
+ })
+ const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response(sse(textEvents)))
+ vi.stubGlobal('fetch', fetch)
+ const stream = ctx.llm.stream(options({
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'prompt with lone \uDC00 surrogate' }] }],
+ }))
+ for await (const chunk of stream) {
+ if (chunk.type === 'finish') expect(chunk.reason.kind).toBe('stop')
+ }
+ const rawPayload = fetch.mock.calls[0]?.[1]?.body
+ if (typeof rawPayload !== 'string') throw new Error('Expected raw string payload')
+ expect(rawPayload).not.toContain('\\ud83d')
+ expect(rawPayload).not.toContain('\\udc00')
+ const parsed = JSON.parse(rawPayload) as { dsh_messages_test: { value: string }; messages: [{ content: [{ text: string }] }] }
+ expect(parsed.dsh_messages_test.value).toBe('lone \uFFFD surrogate')
+ expect(parsed.messages[0].content[0].text).toBe('prompt with lone \uFFFD surrogate')
+ })
})Verification
|
|
Thanks — adopted, and verified on 0.2.1-rc.5. I had originally sanitized the serialized payload with a regex over Local results after patching
The last point is the one I care about most: with the raw text preserved, a gateway parse failure is self-diagnosing instead of a bare |
Uh oh!
There was an error while loading. Please reload this page.
Environment
0.2.1-rc.5(npm global install), Windows 11deepseek-official(Messages API,https://api.deepseek.com/anthropic/v1/messages)dsh_session_logextension activeSymptom
Every request in one long-running session fails with HTTP 400, and the UI only shows:
No detail, no request id, retries never help. The same session works fine on another provider route.
The actual response body
Captured at the wire (the body is plain text, not JSON):
Root cause
dsh_session_logships stored session events verbatim into the request body. One storedtool/resulttext ends with a lone high surrogate (in my case an emoji that an upstream memory-search tool truncated at a UTF-16 code-unit boundary, e.g.hit.text.slice(0, 600)).JSON.stringifyserializes a lone surrogate as\uD8xx. That is syntactically valid JSON for JavaScript, but Rustserde_jsonrejects it — and because it sits inside the single request body, the entire request is rejected,messagesandtoolsincluded.Minimal reproduction
Same payload, only replacing the lone surrogate with
\uFFFD(and a bogusmodelto keep the check cheap), flips the response fromUnexpected end of hex escapeto a normalmodel not founderror — i.e. the body parses again.Why it is hard to diagnose
providerError()indsh-llm-deepseekexpects{ error: { message } }. When the provider answers with plain text (or any non-JSON error shape), the message degrades to the genericDeepSeek Messages request failed (400)and the parser's real explanation (unexpected end of hex escape …) is thrown away. The user sees "400" with no way to learn why.Suggested fixes
extensions.payload(or inwireEvent()fordsh_session_log), replace unpaired surrogates with U+FFFD beforeJSON.stringify, so one bad character in history cannot poison the whole request. The JSON text is what is sent, so this is the only place that can fix already-poisoned sessions.LlmErrormessage when it is not JSON (currently the cause text is lost entirely).String#slice/substringcut of user or tool text should be code-point safe ([...text].slice(0, n).join("")).dsh-compaction-tool-result-pruneralready does this correctly; the memory-search path that produced my data did not.Impact
Any long session whose history contains a truncated emoji becomes permanently unusable on the Messages route, with no user-visible explanation — the session has to be abandoned or moved to another provider.
All reactions