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
16 changes: 8 additions & 8 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Login to Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
huggingface/responses-js
Expand All @@ -26,13 +26,13 @@ jobs:
type=sha,enable=true,prefix=sha-,format=short,sha-len=8

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0

- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0
uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0

- name: Build and Publish image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
Expand All @@ -50,7 +50,7 @@ jobs:
needs: ["build-and-publish"]
steps:
- name: Inject slug/short variables
uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0
uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0

- name: Gen values
run: |
Expand All @@ -62,7 +62,7 @@ jobs:
echo "VALUES=$(echo "$VALUES" | yq -o=json | jq tostring)" >> $GITHUB_ENV

- name: Deploy on infra-deployments
uses: aurelien-baudet/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 # v4.0.0
uses: aurelien-baudet/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 # v4.0.0
with:
workflow: Update application single value
repo: huggingface/infra-deployments
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "18"
node-version: "22"

- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10.10.0

Expand All @@ -30,7 +30,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV

- name: Setup pnpm cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand Down
104 changes: 61 additions & 43 deletions src/routes/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,66 @@ export const postCreateResponse = async (
req: ValidatedRequest<CreateResponseParams>,
res: ExpressResponse
): Promise<void> => {
// To avoid duplicated code, we run all requests as stream.
const events = runCreateResponseStream(req, res);

// Then we return in the correct format depending on the user 'stream' flag.
if (req.body.stream) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
console.debug("Stream request");
for await (const event of events) {
console.debug(`Event #${event.sequence_number}: ${event.type}`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
res.end();
} else {
console.debug("Non-stream request");
for await (const event of events) {
if (event.type === "response.completed" || event.type === "response.failed") {
console.debug(event.type);
res.json(event.response);
// Auth is checked before any event is produced: once the stream has started
// writing, it is too late to send an error status.
const apiKey = req.headers.authorization?.split(" ")[1];
if (!apiKey) {
res.status(401).json({
success: false,
error: "Unauthorized",
});
return;
}

// Express 4 does not catch errors from async handlers: anything thrown here
// becomes an unhandled rejection and kills the process, so the whole handler
// is wrapped.
try {
// To avoid duplicated code, we run all requests as stream.
const events = runCreateResponseStream(req, apiKey);

// Then we return in the correct format depending on the user 'stream' flag.
if (req.body.stream) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
console.debug("Stream request");
for await (const event of events) {
console.debug(`Event #${event.sequence_number}: ${event.type}`);
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
res.end();
} else {
console.debug("Non-stream request");
for await (const event of events) {
if (event.type === "response.completed" || event.type === "response.failed") {
console.debug(event.type);
res.json(event.response);
break;
}
}
}
} catch (error) {
console.error("Error in postCreateResponse:", error);
if (!res.headersSent) {
res.status(500).json({
success: false,
error: errorMessage(error, "Internal server error"),
});
} else {
res.end();
}
}
};

function errorMessage(error: unknown, fallback: string): string {
return typeof error === "object" &&
error &&
"message" in error &&
typeof (error as { message: unknown }).message === "string"
? (error as { message: string }).message
: fallback;
}

/*
* Top-level stream.
*
Expand All @@ -88,7 +124,7 @@ export const postCreateResponse = async (
*/
async function* runCreateResponseStream(
req: ValidatedRequest<CreateResponseParams>,
res: ExpressResponse
apiKey: string
): AsyncGenerator<PatchedResponseStreamEvent> {
let sequenceNumber = 0;
// Prepare response object that will be iteratively populated
Expand Down Expand Up @@ -134,25 +170,17 @@ async function* runCreateResponseStream(

// Any events (LLM call, MCP call, list tools, etc.)
try {
for await (const event of innerRunStream(req, res, responseObject)) {
for await (const event of innerRunStream(req, apiKey, responseObject)) {
yield { ...event, sequence_number: sequenceNumber++ };
}
} catch (error) {
// Error event => stop
console.error("Error in stream:", error);

const message =
typeof error === "object" &&
error &&
"message" in error &&
typeof (error as { message: unknown }).message === "string"
? (error as { message: string }).message
: "An error occurred in stream";

responseObject.status = "failed";
responseObject.error = {
code: "server_error",
message,
message: errorMessage(error, "An error occurred in stream"),
};
yield {
type: "response.failed",
Expand All @@ -173,20 +201,10 @@ async function* runCreateResponseStream(

async function* innerRunStream(
req: ValidatedRequest<CreateResponseParams>,
res: ExpressResponse,
apiKey: string,
responseObject: IncompleteResponse
): AsyncGenerator<PatchedResponseStreamEvent> {
// Retrieve API key from headers
const apiKey = req.headers.authorization?.split(" ")[1];
if (!apiKey) {
res.status(401).json({
success: false,
error: "Unauthorized",
});
return;
}

// Forward headers (except authorization handled separately)
// Forward headers (the exclusion set covers authorization)
const defaultHeaders = Object.fromEntries(
Object.entries(req.headers).filter(([key]) => !NOT_FORWARDED_HEADERS.has(key.toLowerCase()))
) as Record<string, string>;
Expand Down Expand Up @@ -518,7 +536,7 @@ async function* listMcpToolsStream(
* Call LLM and stream the response.
*/
async function* handleOneTurnStream(
apiKey: string | undefined,
apiKey: string,
payload: ChatCompletionCreateParamsStreaming,
responseObject: IncompleteResponse,
mcpToolsMapping: Record<string, McpServerParams>,
Expand Down
12 changes: 12 additions & 0 deletions tests/responses.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,18 @@ describe("responses.js", function () {
assert.equal(output[1].content[0].type, "output_text");
assert.equal(typeof output[1].content[0].text, "string");
});

it("unauthenticated request gets a 401 and does not crash the server", async function () {
const response = await fetch("http://localhost:3000/v1/responses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "Qwen/Qwen2.5-VL-7B-Instruct", input: "hi" }),
});
assert.equal(response.status, 401);

const health = await fetch("http://localhost:3000/health");
assert.equal(health.status, 200);
});
});

function dropConsecutiveEvents(events) {
Expand Down