Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ claude-adapter --port 3000
```
</details>

<details>
<summary><strong>DeepSeek thinking mode 400: reasoning_content must be passed back</strong></summary>

DeepSeek thinking mode requires `reasoning_content` from prior assistant turns to be sent back on follow-up requests.

Claude Adapter auto-detects this behavior from conversation history: if previous assistant turns include `thinking` blocks, it replays them as `reasoning_content` on follow-up requests.
</details>

<details>
<summary><strong>Authentication Failures</strong></summary>

Expand Down
19 changes: 17 additions & 2 deletions src/converters/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ function convertMessage(
// Assistant message with content blocks
// Note: We still use processAssistantContentBlocks for deduplication logic,
// even if we don't use the tool_calls output in XML mode (to keep state consistent)
const { textContent, toolCalls } = processAssistantContentBlocks(msg.content, ctx);
const { textContent, thinkingContent, thinkingSignature, toolCalls } = processAssistantContentBlocks(msg.content, ctx);

// Skip assistant prefill messages when content is just a JSON starter
if (toolCalls.length === 0 && textContent && isAssistantPrefill(textContent)) {
Expand Down Expand Up @@ -282,6 +282,12 @@ function convertMessage(
if (toolCalls.length > 0) {
(assistantMsg as any).tool_calls = toolCalls;
}
if (thinkingContent) {
(assistantMsg as any).reasoning_content = thinkingContent;
}
if (thinkingSignature) {
(assistantMsg as any).reasoning_signature = thinkingSignature;
}

result.push(assistantMsg);
}
Expand Down Expand Up @@ -354,14 +360,23 @@ function processAssistantContentBlocks(
ctx: IdDeduplicationContext
): {
textContent: string;
thinkingContent: string;
thinkingSignature?: string;
toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>;
} {
let textContent = '';
let thinkingContent = '';
let thinkingSignature: string | undefined;
const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = [];

for (const block of blocks) {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'thinking') {
thinkingContent += block.thinking;
if (block.signature) {
thinkingSignature = block.signature;
}
} else if (block.type === 'tool_use') {
const toolUse = block as AnthropicToolUseBlock;
let idToUse = toolUse.id;
Expand Down Expand Up @@ -406,5 +421,5 @@ function processAssistantContentBlocks(
}
}

return { textContent, toolCalls };
return { textContent, thinkingContent, thinkingSignature, toolCalls };
}
9 changes: 9 additions & 0 deletions src/converters/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export function convertResponseToAnthropic(
// Build content blocks
const content: AnthropicContentBlock[] = [];

if (message.reasoning_content) {
const signature = (message as any).reasoning_signature ?? (message as any).signature;
content.push({
type: 'thinking',
thinking: message.reasoning_content,
...(signature ? { signature } : {}),
});
}

// Add text content if present
if (message.content) {
content.push({
Expand Down
88 changes: 86 additions & 2 deletions src/converters/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface StreamingState {
hasStarted: boolean;
textContent: string;
textBlockOpen: boolean;
thinkingBlockOpen: boolean;
}

/**
Expand All @@ -79,6 +80,7 @@ export async function streamOpenAIToAnthropic(
hasStarted: false,
textContent: '',
textBlockOpen: false,
thinkingBlockOpen: false,
};

// Access the underlying Node.js response for SSE streaming
Expand Down Expand Up @@ -132,6 +134,11 @@ function processChunk(

// Handle text content
if (delta.content) {
if (state.thinkingBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.thinkingBlockOpen = false;
state.contentBlockIndex++;
}
if (!state.textBlockOpen) {
sendContentBlockStart(state.contentBlockIndex, 'text', '', raw);
state.textBlockOpen = true;
Expand All @@ -141,6 +148,35 @@ function processChunk(
sendTextDelta(state.contentBlockIndex, delta.content, raw);
}

if (delta.reasoning_content) {
if (state.textBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.textBlockOpen = false;
state.textContent = '';
state.contentBlockIndex++;
}
if (!state.thinkingBlockOpen) {
sendContentBlockStart(state.contentBlockIndex, 'thinking', '', raw);
state.thinkingBlockOpen = true;
}
sendThinkingDelta(state.contentBlockIndex, delta.reasoning_content, raw);
}

const reasoningSignature = delta.reasoning_signature ?? delta.signature;
if (reasoningSignature) {
if (state.textBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.textBlockOpen = false;
state.textContent = '';
state.contentBlockIndex++;
}
if (!state.thinkingBlockOpen) {
sendContentBlockStart(state.contentBlockIndex, 'thinking', '', raw);
state.thinkingBlockOpen = true;
}
sendSignatureDelta(state.contentBlockIndex, reasoningSignature, raw);
}

// Handle tool calls
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
Expand All @@ -156,6 +192,11 @@ function processChunk(
state.textContent = '';
state.contentBlockIndex++;
}
if (state.thinkingBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.thinkingBlockOpen = false;
state.contentBlockIndex++;
}

for (const toolCall of state.currentToolCalls.values()) {
sendContentBlockStop(toolCall.blockIndex, raw);
Expand All @@ -172,6 +213,11 @@ function processToolCallDelta(

// Check if this is a new tool call
if (!state.currentToolCalls.has(index)) {
if (state.thinkingBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.thinkingBlockOpen = false;
state.contentBlockIndex++;
}
if (state.textBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.textBlockOpen = false;
Expand Down Expand Up @@ -238,7 +284,7 @@ function sendMessageStart(state: StreamingState, raw: any): void {

function sendContentBlockStart(
index: number,
type: 'text' | 'tool_use',
type: 'text' | 'tool_use' | 'thinking',
textOrName: string,
raw: any,
id?: string
Expand All @@ -247,13 +293,15 @@ function sendContentBlockStart(

if (type === 'text') {
contentBlock = { type: 'text', text: '' };
} else {
} else if (type === 'tool_use') {
contentBlock = {
type: 'tool_use',
id: id || generateToolUseId(),
name: textOrName,
input: {},
};
} else {
contentBlock = { type: 'thinking', thinking: '' };
}

const event = {
Expand All @@ -276,6 +324,30 @@ function sendTextDelta(index: number, text: string, raw: any): void {
sendSSE(event, raw);
}

function sendThinkingDelta(index: number, thinking: string, raw: any): void {
const event = {
type: 'content_block_delta',
index,
delta: {
type: 'thinking_delta',
thinking,
},
};
sendSSE(event, raw);
}

function sendSignatureDelta(index: number, signature: string, raw: any): void {
const event = {
type: 'content_block_delta',
index,
delta: {
type: 'signature_delta',
signature,
},
};
sendSSE(event, raw);
}

function sendInputJsonDelta(index: number, partialJson: string, raw: any): void {
const event = {
type: 'content_block_delta',
Expand All @@ -297,6 +369,18 @@ function sendContentBlockStop(index: number, raw: any): void {
}

function finishStream(state: StreamingState, raw: any): void {
if (state.textBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.textBlockOpen = false;
state.textContent = '';
state.contentBlockIndex++;
}
if (state.thinkingBlockOpen) {
sendContentBlockStop(state.contentBlockIndex, raw);
state.thinkingBlockOpen = false;
state.contentBlockIndex++;
}

// Determine stop reason
const hasToolCalls = state.currentToolCalls.size > 0;
const stopReason = hasToolCalls ? 'tool_use' : 'end_turn';
Expand Down
111 changes: 99 additions & 12 deletions src/server/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,14 @@ async function handleNonStreamingRequest(
): Promise<void> {
log.debug('Making non-streaming request');

const response = await openai.chat.completions.create({
...openaiRequest,
stream: false,
});
const response = await createCompletionWithReasoningFallback(
openai,
{
...openaiRequest,
stream: false,
},
log
);

log.debug('Response received', {
finishReason: response.choices[0]?.finish_reason,
Expand Down Expand Up @@ -142,10 +146,14 @@ async function handleStreamingRequest(
): Promise<void> {
log.debug('Making streaming request');

const stream = await openai.chat.completions.create({
...openaiRequest,
stream: true,
} as OpenAI.ChatCompletionCreateParamsStreaming);
const stream = await createCompletionWithReasoningFallback(
openai,
{
...openaiRequest,
stream: true,
} as OpenAI.ChatCompletionCreateParamsStreaming,
log
);

await streamOpenAIToAnthropic(stream as any, reply, originalModel, provider);
log.debug('Streaming completed');
Expand All @@ -164,10 +172,14 @@ async function handleXmlStreamingRequest(
): Promise<void> {
log.debug('Making XML streaming request (experimental)');

const stream = await openai.chat.completions.create({
...openaiRequest,
stream: true,
} as OpenAI.ChatCompletionCreateParamsStreaming);
const stream = await createCompletionWithReasoningFallback(
openai,
{
...openaiRequest,
stream: true,
} as OpenAI.ChatCompletionCreateParamsStreaming,
log
);

await streamXmlOpenAIToAnthropic(stream as any, reply, originalModel, provider);
log.debug('XML streaming completed');
Expand Down Expand Up @@ -200,4 +212,79 @@ function handleError(
reply.code(errorResponse.status).send({ error: errorResponse.error });
}

function hasReasoningContent(messages: any[]): boolean {
return messages.some(msg =>
msg?.role === 'assistant' && (
(typeof msg?.reasoning_content === 'string' && msg.reasoning_content.length > 0) ||
(typeof msg?.reasoning_signature === 'string' && msg.reasoning_signature.length > 0) ||
(typeof msg?.signature === 'string' && msg.signature.length > 0)
)
);
}

function stripReasoningContent(request: any): any {
if (!Array.isArray(request?.messages)) {
return request;
}

return {
...request,
messages: request.messages.map((msg: any) => {
if (msg?.role !== 'assistant' || !Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) {
if (!Object.prototype.hasOwnProperty.call(msg, 'reasoning_signature') && !Object.prototype.hasOwnProperty.call(msg, 'signature')) {
return msg;
}
}

const { reasoning_content, reasoning_signature, signature, ...rest } = msg;
return rest;
}),
};
}

function isReasoningContentValidationError(error: any): boolean {
const status = error?.status;
if (status !== 400 && status !== 422) {
return false;
}

const directMessage = typeof error?.message === 'string' ? error.message : '';
const bodyMessage = typeof error?.error?.message === 'string' ? error.error.message : '';
const serialized = JSON.stringify(error?.error ?? error?.response ?? error?.body ?? '');
const merged = `${directMessage} ${bodyMessage} ${serialized}`.toLowerCase();

if (merged.includes('must be passed back') && merged.includes('thinking mode')) {
return true;
}

if (merged.includes('reasoning_content') && (
merged.includes('unknown field') ||
merged.includes('invalid') ||
merged.includes('not allowed') ||
merged.includes('should not include')
Comment on lines +260 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry when providers reject reasoning_signature fields

The fallback classifier only treats errors as retryable when the message mentions reasoning_content, so requests that fail on reasoning_signature/signature (for example, unknown field reasoning_signature) will not be retried even though stripReasoningContent is designed to remove those fields. This leaves a real compatibility regression for providers that accept chat completions but reject signature fields, and the request fails instead of taking the intended one-time downgrade path.

Useful? React with 👍 / 👎.

)) {
return true;
}

return false;
}

async function createCompletionWithReasoningFallback(
openai: OpenAI,
request: any,
log: RequestLogger
): Promise<any> {
try {
return await openai.chat.completions.create(request);
} catch (error) {
if (!hasReasoningContent(request.messages || []) || !isReasoningContentValidationError(error)) {
throw error;
}

log.warn('Provider rejected reasoning_content; retrying once without reasoning_content');
const downgradedRequest = stripReasoningContent(request);
return openai.chat.completions.create(downgradedRequest);
}
}


Loading