Skip to content
Merged
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
100 changes: 53 additions & 47 deletions lib/server/proxy/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
createStreamCloser,
toUpstreamTimeoutMessage,
} from '../shared/upstream-timeout';
import { extractErrorMessage } from '../shared/http';
import {
markServerTool,
normalizeToolName,
Expand Down Expand Up @@ -108,7 +109,7 @@ interface OpenAIChatChoice {
}

interface OpenAIStreamError {
error?: { message?: string };
error?: { message?: string; status?: number };
}

interface OpenAIUsage {
Expand Down Expand Up @@ -1044,18 +1045,25 @@ const mapOpenAIStreamToAnthropicSSE = (
let buffer = '';
const rejectStream = (
message = 'Upstream SSE frame exceeds the maximum size',
status?: number,
): void => {
streamRejected = true;
enqueueEvent({
type: 'error',
error: {
// An oversized frame is a malformed stream, but an upstream
// deadline is the server failing — and `api_error` is the type
// clients treat as retryable. Reporting a timeout as
// invalid_request_error would tell them never to retry.
type: message.includes('did not produce output')
? 'api_error'
: 'invalid_request_error',
// An upstream status names the failure precisely, so it decides
// the type: 429 has to arrive as `rate_limit_error` or a client
// that retries on that type alone stops retrying an exhausted
// quota. Without one, fall back to the message: an oversized frame
// is a malformed stream (`invalid_request_error`), while an
// upstream deadline is the server failing (`api_error`, the type
// clients treat as retryable).
type:
typeof status === 'number'
? anthropicErrorType(status)
: message.includes('did not produce output')
? 'api_error'
: 'invalid_request_error',
message,
},
});
Expand Down Expand Up @@ -1137,7 +1145,10 @@ const mapOpenAIStreamToAnthropicSSE = (

const upstreamError = chunk as OpenAIStreamError;
if (upstreamError.error?.message) {
rejectStream(upstreamError.error.message);
rejectStream(
upstreamError.error.message,
upstreamError.error.status,
);
return;
}
processChunk(chunk);
Expand Down Expand Up @@ -1283,9 +1294,23 @@ const createAnthropicServerToolEventStream = (
}

if (!upstreamResponse.ok || !upstreamResponse.body) {
// A rate limit has to arrive as `rate_limit_error`, or a client that
// retries on that type alone will treat an exhausted quota as a
// generic failure and stop retrying — so the upstream status drives
// the event type even though the envelope is already streaming and
// the HTTP status cannot be changed.
const message = upstreamResponse.ok
? 'Upstream request failed'
: await getUpstreamErrorMessage(upstreamResponse).catch(
() => 'Upstream request failed',
);

enqueueEvent({
type: 'error',
error: { type: 'api_error', message: 'Upstream request failed' },
error: {
type: anthropicErrorType(upstreamResponse.status),
message,
Comment thread
orangeboyChen marked this conversation as resolved.
},
});
controller.close();
return;
Expand Down Expand Up @@ -1337,27 +1362,6 @@ const createAnthropicServerToolEventStream = (
});
};

const extractErrorMessage = (value: unknown): string | null => {
if (typeof value === 'string') {
try {
return extractErrorMessage(JSON.parse(value) as unknown) ?? value;
} catch {
return value;
}
}
if (!value || typeof value !== 'object') return null;

const payload = value as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const detail = extractErrorMessage(payload.detail);
if (detail) return detail;
if (typeof payload.message === 'string') return payload.message;
return extractErrorMessage(payload.error);
};

const getUpstreamErrorMessage = async (response: Response): Promise<string> => {
const text = await response.text();
if (!text) return 'Upstream CodeBuddy request failed';
Expand Down Expand Up @@ -1429,26 +1433,28 @@ export const handleMessagesRequest = async (
}
};

export const anthropicErrorType = (status: number): string =>
status === 401
? 'authentication_error'
: status === 403
? 'permission_error'
: status === 404
? 'not_found_error'
: status === 413
? 'request_too_large'
: status === 429
? 'rate_limit_error'
: status === 529
? 'overloaded_error'
: status >= 500
? 'api_error'
: 'invalid_request_error';

export const createAnthropicError = (
status: number,
message: string,
): Response => {
const type =
status === 401
? 'authentication_error'
: status === 403
? 'permission_error'
: status === 404
? 'not_found_error'
: status === 413
? 'request_too_large'
: status === 429
? 'rate_limit_error'
: status === 529
? 'overloaded_error'
: status >= 500
? 'api_error'
: 'invalid_request_error';
const type = anthropicErrorType(status);

return Response.json(
{
Expand Down
117 changes: 104 additions & 13 deletions lib/server/proxy/web-search-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
runWebFetchResult,
runWebSearchResult,
} from '../search';
import { extractErrorMessage } from '../shared/http';

import type { ChatRequestBody } from './codebuddy';
import {
Expand Down Expand Up @@ -78,33 +79,107 @@ export interface ChatCompletionPayload {
message?: ChatCompletionMessage;
}>;
created?: number;
error?: { message?: string };
/**
* `status` is the upstream HTTP status, carried so a downstream mapper can
* name the real error type instead of guessing it from the message text. It
* is absent for a payload that already reported an error of its own.
*/
error?: { message?: string; status?: number };
id?: string;
model?: string;
object?: string;
usage?: unknown;
}

const readBufferedChatCompletionPayload = async (
/**
* Rebuilds a failed upstream response so its body can be read again.
*
* A `Response` body can only be consumed once. The loop reads it to decide
* whether the model asked for a server tool, and handing the same object back
* used to leave the route layer — which reads it again to build the answer the
* client actually sees — with a spent body: the second read threw
* "Body already used" and the client got a 500 in place of the real upstream
* status. Draining it here and replaying the bytes in a fresh response keeps
* both reads working and preserves the body verbatim, so an upstream error
* detail that is not valid JSON still reaches the client intact.
*
* `content-length` and `content-encoding` are dropped: the body is re-emitted
* rather than re-encoded, and a stale length would describe bytes the upstream
* compressed before this layer ever saw them.
*/
const buildServerToolFailureResponse = async (
response: Response,
): Promise<ChatCompletionPayload> => {
let payload: ChatCompletionPayload;
): Promise<Response> => {
const headers = new Headers(response.headers);

headers.delete('content-length');
headers.delete('content-encoding');
headers.set('content-type', 'application/json');

return new Response(await response.text(), {
headers,
status: response.status,
statusText: response.statusText,
});
};

/**
* Parses a buffered upstream body, tolerating a failure that is not JSON.
*
* A successful response must be well-formed — anything else is a bug worth
* surfacing — but a failure status already tells the caller everything it
* needs to know, and its body may legitimately be an HTML error page or a
* bare string. Rejecting on those would turn an ordinary outage into an
* unhandled rejection.
*/
const parseBufferedPayload = (
buffered: string,
ok: boolean,
): ChatCompletionPayload => {
try {
payload = (await response.json()) as ChatCompletionPayload;
return JSON.parse(buffered) as ChatCompletionPayload;
} catch (error) {
if (response.ok) {
if (ok) {
throw error;
}

payload = {};
return {};
}
};

const readBufferedChatCompletionPayload = async (
response: Response,
): Promise<ChatCompletionPayload> => {
// Cloned so the failure path can replay the body verbatim; see
// {@link buildServerToolFailureResponse}.
const buffered = await response.clone().text();
const payload = parseBufferedPayload(buffered, response.ok);

if (!response.ok || payload.error) {
const ownMessage = payload.error?.message;
// `extractErrorMessage` digs a nested message out of the payload, so
// `{"error":{"message":"x"}}` reaches the client as "x" rather than as a
// JSON string. The raw body is the fallback: a payload carrying only a
// code has no message to find, and the JSON is still the only record of
// what happened. An empty body says nothing, so it falls all the way
// through to the generic message instead of winning on being non-null.
const detail = buffered.trim();

if (!response.ok && !payload.error) {
return {
...payload,
error: {
message: `Upstream request failed with status ${response.status}`,
// The upstream's own explanation — a rate-limit code, a reset
// timestamp — beats the proxy's generic "Upstream CodeBuddy request
// failed", which says only that something failed and leaves the client
// no way to tell what.
//
// `status` travels with the frame so a downstream mapper can name the
// real error type instead of guessing it from the message text.
message:
extractErrorMessage(payload) ??
ownMessage ??
(detail || `Upstream request failed with status ${response.status}`),
...(response.ok ? {} : { status: response.status }),
},
};
}
Expand Down Expand Up @@ -1501,10 +1576,18 @@ export const executeWebSearchLoop = async ({
return { body: loopBody, executions, response };
}

payload = (await response.json()) as ChatCompletionPayload;
// The payload is only needed to detect a tool call or a failure, so read
// the body once and reuse it: the caller reads it again to build the
// client's answer, and a spent body would surface as a 500.
const buffered = await response.clone().text();
payload = parseBufferedPayload(buffered, response.ok);

if (!response.ok || payload.error) {
return { body: loopBody, executions, response };
return {
body: loopBody,
executions,
response: await buildServerToolFailureResponse(response),
};
}

usage = sumUsage(usage, payload.usage);
Expand Down Expand Up @@ -1652,11 +1735,19 @@ export const executeWebSearchLoop = async ({
},
'buffer',
);
payload = (await finalResponse.json()) as ChatCompletionPayload;
// Cloned before the read so the failure path can replay the body verbatim
// rather than hand back a spent response the caller cannot read again.
const finalBuffered = await finalResponse.clone().text();
payload = parseBufferedPayload(finalBuffered, finalResponse.ok);

usage = sumUsage(usage, payload.usage);

if (!finalResponse.ok || payload.error) {
return { body: loopBody, executions, response: finalResponse };
return {
body: loopBody,
executions,
response: await buildServerToolFailureResponse(finalResponse),
};
}

return {
Expand Down
29 changes: 29 additions & 0 deletions lib/server/shared/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,35 @@ export const getRequestHeaderMap = (
}, {});
};

/**
* Digs the human-readable explanation out of an upstream error body.
*
* Upstream shapes nest the real message at different depths — `detail`,
* `error.message`, or a JSON-encoded string standing in for either — so the
* search recurses until it finds text. Returns null when nothing readable is
* there, letting the caller fall back to the raw body.
*/
export const extractErrorMessage = (value: unknown): string | null => {
if (typeof value === 'string') {
try {
return extractErrorMessage(JSON.parse(value) as unknown) ?? value;
} catch {
return value;
}
}
if (!value || typeof value !== 'object') return null;

const payload = value as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
const detail = extractErrorMessage(payload.detail);
if (detail) return detail;
if (typeof payload.message === 'string') return payload.message;
return extractErrorMessage(payload.error);
};

export const createErrorResponse = (
status: number,
message: string,
Expand Down
Loading
Loading