429 lines
12 KiB
JavaScript
429 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import test from "node:test";
|
||
|
|
|
||
|
|
import OpenAI from "openai";
|
||
|
|
|
||
|
|
|
||
|
|
function jsonResponse(value, status = 200, extraHeaders = {}) {
|
||
|
|
return new Response(JSON.stringify(value), {
|
||
|
|
status,
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"X-Request-ID": "req_js_contract",
|
||
|
|
...extraHeaders,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function inputItems(input) {
|
||
|
|
if (typeof input === "string") {
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
id: "msg_input_contract",
|
||
|
|
type: "message",
|
||
|
|
role: "user",
|
||
|
|
content: [{ type: "input_text", text: input }],
|
||
|
|
status: "completed",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
}
|
||
|
|
return input.map((item, index) => ({
|
||
|
|
id: item.id ?? `item_contract_${index}`,
|
||
|
|
status: item.status ?? "completed",
|
||
|
|
...item,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function completedResponse({ id, model, previousResponseId, input, output }) {
|
||
|
|
return {
|
||
|
|
id,
|
||
|
|
object: "response",
|
||
|
|
created_at: 1788739200,
|
||
|
|
completed_at: 1788739201,
|
||
|
|
status: "completed",
|
||
|
|
error: null,
|
||
|
|
incomplete_details: null,
|
||
|
|
instructions: null,
|
||
|
|
model,
|
||
|
|
output,
|
||
|
|
parallel_tool_calls: false,
|
||
|
|
previous_response_id: previousResponseId,
|
||
|
|
store: true,
|
||
|
|
tool_choice: "auto",
|
||
|
|
tools: [],
|
||
|
|
metadata: {},
|
||
|
|
usage: null,
|
||
|
|
_input: inputItems(input),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function publicResponse(response) {
|
||
|
|
const value = { ...response };
|
||
|
|
delete value._input;
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function sseResponse(response) {
|
||
|
|
const initial = {
|
||
|
|
...publicResponse(response),
|
||
|
|
status: "in_progress",
|
||
|
|
completed_at: null,
|
||
|
|
output: [],
|
||
|
|
};
|
||
|
|
const item = response.output[0];
|
||
|
|
const events = [];
|
||
|
|
const append = (event) => {
|
||
|
|
events.push({ ...event, sequence_number: events.length });
|
||
|
|
};
|
||
|
|
append({ type: "response.created", response: initial });
|
||
|
|
append({ type: "response.in_progress", response: initial });
|
||
|
|
if (item.type === "message") {
|
||
|
|
const part = item.content[0];
|
||
|
|
append({
|
||
|
|
type: "response.output_item.added",
|
||
|
|
output_index: 0,
|
||
|
|
item: { ...item, status: "in_progress", content: [] },
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.content_part.added",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
content_index: 0,
|
||
|
|
part: { type: "output_text", text: "", annotations: [] },
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.output_text.delta",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
content_index: 0,
|
||
|
|
delta: part.text,
|
||
|
|
logprobs: [],
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.output_text.done",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
content_index: 0,
|
||
|
|
text: part.text,
|
||
|
|
logprobs: [],
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.content_part.done",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
content_index: 0,
|
||
|
|
part,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
append({
|
||
|
|
type: "response.output_item.added",
|
||
|
|
output_index: 0,
|
||
|
|
item: { ...item, status: "in_progress", arguments: "" },
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.function_call_arguments.delta",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
delta: item.arguments,
|
||
|
|
});
|
||
|
|
append({
|
||
|
|
type: "response.function_call_arguments.done",
|
||
|
|
item_id: item.id,
|
||
|
|
output_index: 0,
|
||
|
|
name: item.name,
|
||
|
|
arguments: item.arguments,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
append({ type: "response.output_item.done", output_index: 0, item });
|
||
|
|
append({ type: "response.completed", response: publicResponse(response) });
|
||
|
|
const body = events
|
||
|
|
.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
||
|
|
.join("");
|
||
|
|
return new Response(body, {
|
||
|
|
status: 200,
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/event-stream",
|
||
|
|
"X-Request-ID": "req_js_contract",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
function createContractFetch() {
|
||
|
|
const responses = new Map();
|
||
|
|
const idempotency = new Map();
|
||
|
|
let sequence = 0;
|
||
|
|
|
||
|
|
const fetch = async (input, init) => {
|
||
|
|
const request = input instanceof Request ? input : new Request(input, init);
|
||
|
|
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||
|
|
const url = new URL(request.url);
|
||
|
|
|
||
|
|
if (request.method === "GET" && url.pathname === "/v1/models") {
|
||
|
|
return jsonResponse({
|
||
|
|
object: "list",
|
||
|
|
data: [
|
||
|
|
{
|
||
|
|
id: "assistant:asst_js",
|
||
|
|
object: "model",
|
||
|
|
created: 1788739200,
|
||
|
|
owned_by: "interactive-media",
|
||
|
|
name: "JS contract assistant",
|
||
|
|
capabilities: ["responses", "realtime"],
|
||
|
|
assistant_id: "asst_js",
|
||
|
|
assistant_type: "prompt",
|
||
|
|
runtime_mode: "pipeline",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (request.method === "POST" && url.pathname === "/v1/responses") {
|
||
|
|
const body = await request.clone().json();
|
||
|
|
const idempotencyKey = request.headers.get("idempotency-key");
|
||
|
|
const fingerprint = JSON.stringify(body);
|
||
|
|
if (idempotencyKey && idempotency.has(idempotencyKey)) {
|
||
|
|
const stored = idempotency.get(idempotencyKey);
|
||
|
|
if (stored.fingerprint !== fingerprint) {
|
||
|
|
return jsonResponse(
|
||
|
|
{
|
||
|
|
error: {
|
||
|
|
type: "invalid_request_error",
|
||
|
|
code: "idempotency_key_conflict",
|
||
|
|
message: "Idempotency-Key was used with a different request",
|
||
|
|
param: "Idempotency-Key",
|
||
|
|
},
|
||
|
|
},
|
||
|
|
409,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return jsonResponse(publicResponse(stored.response), 200, {
|
||
|
|
"X-Idempotent-Replayed": "true",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const parent = body.previous_response_id
|
||
|
|
? responses.get(body.previous_response_id)
|
||
|
|
: undefined;
|
||
|
|
const model = body.model ?? parent?.model;
|
||
|
|
sequence += 1;
|
||
|
|
const responseId = `resp_js_${sequence}`;
|
||
|
|
const isToolCall = body.input === "调用客户端工具";
|
||
|
|
const output = isToolCall
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
id: "fc_js_contract",
|
||
|
|
type: "function_call",
|
||
|
|
status: "completed",
|
||
|
|
call_id: "call_js_contract",
|
||
|
|
name: "show_message",
|
||
|
|
arguments: '{"message":"请确认"}',
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: [
|
||
|
|
{
|
||
|
|
id: `msg_js_${sequence}`,
|
||
|
|
type: "message",
|
||
|
|
status: "completed",
|
||
|
|
role: "assistant",
|
||
|
|
content: [
|
||
|
|
{
|
||
|
|
type: "output_text",
|
||
|
|
text: `第 ${sequence} 轮回复`,
|
||
|
|
annotations: [],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
const response = completedResponse({
|
||
|
|
id: responseId,
|
||
|
|
model,
|
||
|
|
previousResponseId: body.previous_response_id ?? null,
|
||
|
|
input: body.input,
|
||
|
|
output,
|
||
|
|
});
|
||
|
|
responses.set(responseId, response);
|
||
|
|
if (idempotencyKey) {
|
||
|
|
idempotency.set(idempotencyKey, { fingerprint, response });
|
||
|
|
}
|
||
|
|
return body.stream ? sseResponse(response) : jsonResponse(publicResponse(response));
|
||
|
|
}
|
||
|
|
|
||
|
|
const inputItemsMatch = url.pathname.match(
|
||
|
|
/^\/v1\/responses\/([^/]+)\/input_items$/,
|
||
|
|
);
|
||
|
|
if (request.method === "GET" && inputItemsMatch) {
|
||
|
|
const response = responses.get(inputItemsMatch[1]);
|
||
|
|
if (!response) return jsonResponse({ error: { message: "Not found" } }, 404);
|
||
|
|
const data = response._input;
|
||
|
|
return jsonResponse({
|
||
|
|
object: "list",
|
||
|
|
data,
|
||
|
|
first_id: data[0]?.id ?? null,
|
||
|
|
last_id: data.at(-1)?.id ?? null,
|
||
|
|
has_more: false,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const responseMatch = url.pathname.match(/^\/v1\/responses\/([^/]+)$/);
|
||
|
|
if (responseMatch && request.method === "GET") {
|
||
|
|
const response = responses.get(responseMatch[1]);
|
||
|
|
return response
|
||
|
|
? jsonResponse(publicResponse(response))
|
||
|
|
: jsonResponse(
|
||
|
|
{
|
||
|
|
error: {
|
||
|
|
type: "invalid_request_error",
|
||
|
|
code: "response_not_found",
|
||
|
|
message: "Response not found",
|
||
|
|
param: null,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
404,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (responseMatch && request.method === "DELETE") {
|
||
|
|
const deleted = responses.delete(responseMatch[1]);
|
||
|
|
return deleted
|
||
|
|
? jsonResponse({ id: responseMatch[1], object: "response", deleted: true })
|
||
|
|
: jsonResponse({ error: { message: "Not found" } }, 404);
|
||
|
|
}
|
||
|
|
return jsonResponse({ error: { message: "Unexpected contract request" } }, 500);
|
||
|
|
};
|
||
|
|
|
||
|
|
return { fetch, responses };
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
test("OpenAPI snapshot exposes the public Responses surface", () => {
|
||
|
|
const openapiPath = new URL("../../backend/contracts/openapi.json", import.meta.url);
|
||
|
|
const openapi = JSON.parse(readFileSync(openapiPath, "utf8"));
|
||
|
|
assert.ok(openapi.paths["/v1/responses"].post);
|
||
|
|
assert.ok(openapi.paths["/v1/responses/{response_id}/input_items"].get);
|
||
|
|
assert.ok(openapi.paths["/v1/responses/{response_id}"].delete);
|
||
|
|
assert.ok(openapi.paths["/v1/models"].get);
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
test("official JavaScript SDK completes three stateful turns and discovery", async () => {
|
||
|
|
const contract = createContractFetch();
|
||
|
|
const client = new OpenAI({
|
||
|
|
apiKey: "sk-rt-js-contract",
|
||
|
|
baseURL: "https://contract.test/v1",
|
||
|
|
fetch: contract.fetch,
|
||
|
|
maxRetries: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
const models = await client.models.list();
|
||
|
|
assert.equal(models.data[0].id, "assistant:asst_js");
|
||
|
|
|
||
|
|
const first = await client.responses.create({
|
||
|
|
model: "assistant:asst_js",
|
||
|
|
input: "第一轮",
|
||
|
|
});
|
||
|
|
const second = await client.responses.create({
|
||
|
|
previous_response_id: first.id,
|
||
|
|
input: "第二轮",
|
||
|
|
});
|
||
|
|
const third = await client.responses.create({
|
||
|
|
previous_response_id: second.id,
|
||
|
|
input: "第三轮",
|
||
|
|
});
|
||
|
|
assert.equal(third.previous_response_id, second.id);
|
||
|
|
assert.equal(third.model, "assistant:asst_js");
|
||
|
|
assert.equal(third.output_text, "第 3 轮回复");
|
||
|
|
|
||
|
|
const page = await client.responses.inputItems.list(third.id, { order: "asc" });
|
||
|
|
assert.equal(page.data[0].type, "message");
|
||
|
|
const retrieved = await client.responses.retrieve(third.id);
|
||
|
|
assert.equal(retrieved.id, third.id);
|
||
|
|
|
||
|
|
const deleted = await client.responses.delete(first.id);
|
||
|
|
assert.equal(deleted.deleted, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
test("official JavaScript SDK parses SSE and Client Tool continuation", async () => {
|
||
|
|
const contract = createContractFetch();
|
||
|
|
const client = new OpenAI({
|
||
|
|
apiKey: "sk-rt-js-contract",
|
||
|
|
baseURL: "https://contract.test/v1",
|
||
|
|
fetch: contract.fetch,
|
||
|
|
maxRetries: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
const stream = await client.responses.create({
|
||
|
|
model: "assistant:asst_js",
|
||
|
|
input: "流式输出",
|
||
|
|
stream: true,
|
||
|
|
});
|
||
|
|
const events = [];
|
||
|
|
for await (const event of stream) events.push(event);
|
||
|
|
assert.deepEqual(
|
||
|
|
events.map((event) => event.sequence_number),
|
||
|
|
Array.from({ length: events.length }, (_, index) => index),
|
||
|
|
);
|
||
|
|
assert.equal(events.at(-1).type, "response.completed");
|
||
|
|
const streamed = await client.responses.retrieve(
|
||
|
|
events.at(-1).response.id,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
streamed.output_text,
|
||
|
|
events.at(-1).response.output[0].content[0].text,
|
||
|
|
);
|
||
|
|
|
||
|
|
const toolStream = await client.responses.create({
|
||
|
|
model: "assistant:asst_js",
|
||
|
|
input: "调用客户端工具",
|
||
|
|
stream: true,
|
||
|
|
});
|
||
|
|
const toolEvents = [];
|
||
|
|
for await (const event of toolStream) toolEvents.push(event);
|
||
|
|
assert.ok(
|
||
|
|
toolEvents.some(
|
||
|
|
(event) => event.type === "response.function_call_arguments.done",
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const tool = toolEvents.at(-1).response;
|
||
|
|
const continued = await client.responses.create({
|
||
|
|
previous_response_id: tool.id,
|
||
|
|
input: [
|
||
|
|
{
|
||
|
|
type: "function_call_output",
|
||
|
|
call_id: tool.output[0].call_id,
|
||
|
|
output: '{"status":"ok"}',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
assert.equal(continued.status, "completed");
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
test("official JavaScript SDK preserves idempotency semantics", async () => {
|
||
|
|
const contract = createContractFetch();
|
||
|
|
const client = new OpenAI({
|
||
|
|
apiKey: "sk-rt-js-contract",
|
||
|
|
baseURL: "https://contract.test/v1",
|
||
|
|
fetch: contract.fetch,
|
||
|
|
maxRetries: 0,
|
||
|
|
});
|
||
|
|
const options = { headers: { "Idempotency-Key": "js-contract-key" } };
|
||
|
|
const request = { model: "assistant:asst_js", input: "幂等请求" };
|
||
|
|
|
||
|
|
const first = await client.responses.create(request, options);
|
||
|
|
const replay = await client.responses.create(request, options);
|
||
|
|
assert.equal(replay.id, first.id);
|
||
|
|
await assert.rejects(
|
||
|
|
client.responses.create(
|
||
|
|
{ model: "assistant:asst_js", input: "不同请求" },
|
||
|
|
options,
|
||
|
|
),
|
||
|
|
(error) => error.status === 409,
|
||
|
|
);
|
||
|
|
});
|