1157 lines
36 KiB
TypeScript
1157 lines
36 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import type { ServerResponse } from "node:http";
|
||
import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
|
||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||
import { z, ZodError } from "zod";
|
||
import {
|
||
CommandCodeError,
|
||
runCommandCode,
|
||
type CommandResult,
|
||
type InstallationStatus,
|
||
} from "./command-code.js";
|
||
import type { BridgeConfig } from "./config.js";
|
||
import { FinalTurnAccumulator } from "./final-turn.js";
|
||
import { openAIError, type CommandUsage } from "./openai.js";
|
||
import type { RequestCoordinator } from "./request-coordinator.js";
|
||
|
||
const responseIdPattern = /^resp_[A-Za-z0-9_-]{1,128}$/;
|
||
const responseIdSchema = z.string().regex(responseIdPattern, "Invalid response ID");
|
||
const roleSchema = z.enum(["system", "developer", "user", "assistant"]);
|
||
|
||
const inputTextPartSchema = z.object({
|
||
type: z.literal("input_text"),
|
||
text: z.string(),
|
||
}).strict();
|
||
|
||
const outputTextPartSchema = z.object({
|
||
type: z.literal("output_text"),
|
||
text: z.string(),
|
||
}).strict();
|
||
|
||
const inputMessageSchema = z.object({
|
||
type: z.literal("message").optional(),
|
||
role: roleSchema,
|
||
content: z.union([
|
||
z.string(),
|
||
z.array(z.union([inputTextPartSchema, outputTextPartSchema])).min(1),
|
||
]),
|
||
}).strict();
|
||
|
||
const textFormatSchema = z.object({
|
||
type: z.literal("text"),
|
||
}).strict();
|
||
|
||
const jsonObjectFormatSchema = z.object({
|
||
type: z.literal("json_object"),
|
||
}).strict();
|
||
|
||
const jsonSchemaFormatSchema = z.object({
|
||
type: z.literal("json_schema"),
|
||
name: z.string().min(1).max(64),
|
||
strict: z.boolean().optional().default(true),
|
||
schema: z.union([z.record(z.unknown()), z.boolean()]),
|
||
}).strict();
|
||
|
||
const responseTextFormatSchema = z.discriminatedUnion("type", [
|
||
textFormatSchema,
|
||
jsonObjectFormatSchema,
|
||
jsonSchemaFormatSchema,
|
||
]);
|
||
|
||
const metadataSchema = z.record(z.string().max(512)).superRefine((metadata, context) => {
|
||
if (Object.keys(metadata).length > 16) {
|
||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Metadata supports at most 16 entries" });
|
||
}
|
||
for (const key of Object.keys(metadata)) {
|
||
if (key.length > 64) {
|
||
context.addIssue({ code: z.ZodIssueCode.custom, message: "Metadata keys support at most 64 characters" });
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
export const responseRequestSchema = z.object({
|
||
model: z.string().min(1),
|
||
input: z.union([z.string(), z.array(inputMessageSchema).min(1)]),
|
||
instructions: z.string().nullable().optional().default(null),
|
||
stream: z.boolean().optional().default(false),
|
||
store: z.boolean().optional().default(true),
|
||
previous_response_id: responseIdSchema.nullable().optional().default(null),
|
||
metadata: metadataSchema.optional().default({}),
|
||
reasoning: z.object({
|
||
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]),
|
||
}).strict().optional(),
|
||
text: z.object({
|
||
format: responseTextFormatSchema,
|
||
}).strict().optional().default({ format: { type: "text" } }),
|
||
}).strict();
|
||
|
||
type ResponseRequest = z.infer<typeof responseRequestSchema>;
|
||
export type ResponseTextFormat = z.infer<typeof responseTextFormatSchema>;
|
||
type ResponseRole = z.infer<typeof roleSchema>;
|
||
type ResponseStatus = "in_progress" | "completed" | "incomplete" | "failed" | "cancelled";
|
||
|
||
interface ResponseInputPart {
|
||
type: "input_text" | "output_text";
|
||
text: string;
|
||
}
|
||
|
||
interface ResponseInputItem {
|
||
id: string;
|
||
type: "message";
|
||
role: ResponseRole;
|
||
content: ResponseInputPart[];
|
||
}
|
||
|
||
interface ResponseOutputText {
|
||
type: "output_text";
|
||
text: string;
|
||
annotations: [];
|
||
}
|
||
|
||
interface ResponseOutputMessage {
|
||
id: string;
|
||
type: "message";
|
||
role: "assistant";
|
||
status: "completed" | "incomplete";
|
||
content: ResponseOutputText[];
|
||
}
|
||
|
||
interface ResponseUsage {
|
||
input_tokens: number;
|
||
output_tokens: number;
|
||
total_tokens: number;
|
||
}
|
||
|
||
interface ResponseError {
|
||
code: string;
|
||
message: string;
|
||
}
|
||
|
||
interface LocalResponse {
|
||
id: string;
|
||
object: "response";
|
||
created_at: number;
|
||
completed_at: number | null;
|
||
status: ResponseStatus;
|
||
error: ResponseError | null;
|
||
incomplete_details: { reason: string } | null;
|
||
instructions: string | null;
|
||
model: string;
|
||
output: ResponseOutputMessage[];
|
||
output_text: string;
|
||
previous_response_id: string | null;
|
||
reasoning: { effort: string | null; summary: null };
|
||
store: boolean;
|
||
metadata: Record<string, string>;
|
||
text: { format: ResponseTextFormat };
|
||
usage: ResponseUsage | null;
|
||
}
|
||
|
||
interface StoredResponse {
|
||
response: LocalResponse;
|
||
input_items: ResponseInputItem[];
|
||
}
|
||
|
||
interface PromptMessage {
|
||
role: ResponseRole;
|
||
content: string;
|
||
}
|
||
|
||
interface StructuredValidation {
|
||
validate: (text: string) => { valid: boolean; errors: string[] };
|
||
}
|
||
|
||
interface AttemptResult {
|
||
result: CommandResult;
|
||
finalChunks: string[];
|
||
}
|
||
|
||
interface ExecutionResult {
|
||
finalText: string;
|
||
chunks: string[];
|
||
usage?: CommandUsage;
|
||
validationErrors?: string[];
|
||
}
|
||
|
||
class ResponseApiError extends Error {
|
||
constructor(
|
||
message: string,
|
||
readonly statusCode: number,
|
||
readonly code: string,
|
||
readonly type = "invalid_request_error",
|
||
readonly param: string | null = null,
|
||
) {
|
||
super(message);
|
||
this.name = "ResponseApiError";
|
||
}
|
||
}
|
||
|
||
class ResponseStoreError extends Error {
|
||
constructor(message: string, readonly cause?: unknown) {
|
||
super(message);
|
||
this.name = "ResponseStoreError";
|
||
}
|
||
}
|
||
|
||
class ResponseStore {
|
||
readonly directory: string;
|
||
|
||
constructor(directory: string) {
|
||
this.directory = path.resolve(directory);
|
||
}
|
||
|
||
async initialize(): Promise<void> {
|
||
await mkdir(this.directory, { recursive: true });
|
||
}
|
||
|
||
private filePath(id: string): string {
|
||
if (!responseIdPattern.test(id)) {
|
||
throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id");
|
||
}
|
||
const resolved = path.resolve(this.directory, `${id}.json`);
|
||
if (path.dirname(resolved) !== this.directory) {
|
||
throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id");
|
||
}
|
||
return resolved;
|
||
}
|
||
|
||
async load(id: string): Promise<StoredResponse | undefined> {
|
||
const filePath = this.filePath(id);
|
||
let source: string;
|
||
try {
|
||
source = await readFile(filePath, "utf8");
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||
throw new ResponseStoreError(`Unable to read stored response '${id}'`, error);
|
||
}
|
||
|
||
try {
|
||
const value: unknown = JSON.parse(source);
|
||
if (!isRecord(value) || !isRecord(value.response) || value.response.id !== id || !Array.isArray(value.input_items)) {
|
||
throw new Error("Stored response shape is invalid");
|
||
}
|
||
return value as unknown as StoredResponse;
|
||
} catch (error) {
|
||
throw new ResponseStoreError(`Stored response '${id}' is corrupt`, error);
|
||
}
|
||
}
|
||
|
||
async loadChain(id: string): Promise<StoredResponse[]> {
|
||
const chain: StoredResponse[] = [];
|
||
const seen = new Set<string>();
|
||
let currentId: string | null = id;
|
||
|
||
while (currentId) {
|
||
if (seen.has(currentId) || seen.size >= 1000) {
|
||
throw new ResponseStoreError("Stored response chain contains a cycle or is too deep");
|
||
}
|
||
seen.add(currentId);
|
||
const stored = await this.load(currentId);
|
||
if (!stored) {
|
||
throw new ResponseApiError(
|
||
`Response '${currentId}' was not found`,
|
||
404,
|
||
"response_not_found",
|
||
"invalid_request_error",
|
||
"previous_response_id",
|
||
);
|
||
}
|
||
chain.unshift(stored);
|
||
currentId = stored.response.previous_response_id;
|
||
}
|
||
|
||
return chain;
|
||
}
|
||
|
||
async save(stored: StoredResponse): Promise<void> {
|
||
const target = this.filePath(stored.response.id);
|
||
const temporary = path.resolve(this.directory, `.${stored.response.id}.${randomHexId()}.tmp`);
|
||
if (path.dirname(temporary) !== this.directory) throw new ResponseStoreError("Invalid temporary response path");
|
||
|
||
try {
|
||
await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
||
await rename(temporary, target);
|
||
} catch (error) {
|
||
try { await unlink(temporary); } catch { /* temporary file may not exist */ }
|
||
throw new ResponseStoreError(`Unable to store response '${stored.response.id}'`, error);
|
||
}
|
||
}
|
||
|
||
async delete(id: string): Promise<boolean> {
|
||
const target = this.filePath(id);
|
||
try {
|
||
await unlink(target);
|
||
return true;
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||
throw new ResponseStoreError(`Unable to delete response '${id}'`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
class ResponsesSseWriter {
|
||
private sequenceNumber = 0;
|
||
private outputStarted = false;
|
||
private outputText = "";
|
||
|
||
constructor(
|
||
private readonly response: ServerResponse,
|
||
private readonly messageId: string,
|
||
) {}
|
||
|
||
event(type: string, payload: Record<string, unknown>): void {
|
||
if (this.response.destroyed || this.response.writableEnded) return;
|
||
const event = { type, ...payload, sequence_number: this.sequenceNumber++ };
|
||
this.response.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||
}
|
||
|
||
begin(initial: LocalResponse): void {
|
||
this.event("response.created", { response: initial });
|
||
this.event("response.in_progress", { response: initial });
|
||
}
|
||
|
||
addText(chunks: string[]): void {
|
||
if (!this.outputStarted) {
|
||
this.outputStarted = true;
|
||
this.event("response.output_item.added", {
|
||
output_index: 0,
|
||
item: {
|
||
id: this.messageId,
|
||
type: "message",
|
||
role: "assistant",
|
||
status: "in_progress",
|
||
content: [],
|
||
},
|
||
});
|
||
this.event("response.content_part.added", {
|
||
item_id: this.messageId,
|
||
output_index: 0,
|
||
content_index: 0,
|
||
part: { type: "output_text", text: "", annotations: [] },
|
||
});
|
||
}
|
||
|
||
for (const delta of chunks) {
|
||
if (delta === "") continue;
|
||
this.outputText += delta;
|
||
this.event("response.output_text.delta", {
|
||
item_id: this.messageId,
|
||
output_index: 0,
|
||
content_index: 0,
|
||
delta,
|
||
});
|
||
}
|
||
}
|
||
|
||
finish(response: LocalResponse, terminalEvent: "response.completed" | "response.incomplete"): void {
|
||
this.ensureFinalText(response.output_text);
|
||
if (this.outputStarted) {
|
||
const outputMessage = response.output[0];
|
||
if (!outputMessage) throw new Error("Response output message is missing");
|
||
const part = outputMessage.content[0];
|
||
if (!part) throw new Error("Response output text part is missing");
|
||
|
||
this.event("response.output_text.done", {
|
||
item_id: this.messageId,
|
||
output_index: 0,
|
||
content_index: 0,
|
||
text: response.output_text,
|
||
});
|
||
this.event("response.content_part.done", {
|
||
item_id: this.messageId,
|
||
output_index: 0,
|
||
content_index: 0,
|
||
part,
|
||
});
|
||
this.event("response.output_item.done", { output_index: 0, item: outputMessage });
|
||
}
|
||
this.event(terminalEvent, { response });
|
||
}
|
||
|
||
failed(response: LocalResponse): void {
|
||
this.event("response.failed", { response });
|
||
}
|
||
|
||
error(code: string, message: string, param: string | null = null): void {
|
||
this.event("error", { code, message, param });
|
||
}
|
||
|
||
end(): void {
|
||
if (!this.response.destroyed && !this.response.writableEnded) this.response.end();
|
||
}
|
||
|
||
private ensureFinalText(finalText: string): void {
|
||
if (!this.outputStarted) {
|
||
this.addText([finalText]);
|
||
return;
|
||
}
|
||
if (this.outputText === finalText) return;
|
||
if (finalText.startsWith(this.outputText)) {
|
||
this.addText([finalText.slice(this.outputText.length)]);
|
||
return;
|
||
}
|
||
throw new Error("Command Code finalText does not match the final structured turn text");
|
||
}
|
||
}
|
||
|
||
interface ResponseRouteDependencies {
|
||
config: BridgeConfig;
|
||
installation: InstallationStatus;
|
||
coordinator: RequestCoordinator;
|
||
}
|
||
|
||
export async function registerResponseRoutes(
|
||
app: FastifyInstance,
|
||
dependencies: ResponseRouteDependencies,
|
||
): Promise<void> {
|
||
const { config, installation, coordinator } = dependencies;
|
||
const store = new ResponseStore(config.resolvedResponseStoreDirectory);
|
||
await store.initialize();
|
||
|
||
app.get("/v1/responses/:response_id", async (request, reply) => {
|
||
try {
|
||
emptyQuerySchema.parse(request.query);
|
||
const id = responseIdFromRequest(request);
|
||
const stored = await store.load(id);
|
||
if (!stored) throw responseNotFound(id);
|
||
return reply.send(stored.response);
|
||
} catch (error) {
|
||
return sendRouteError(reply, error);
|
||
}
|
||
});
|
||
|
||
app.delete("/v1/responses/:response_id", async (request, reply) => {
|
||
try {
|
||
emptyQuerySchema.parse(request.query);
|
||
const id = responseIdFromRequest(request);
|
||
if (!await store.delete(id)) throw responseNotFound(id);
|
||
return reply.send({ id, object: "response", deleted: true });
|
||
} catch (error) {
|
||
return sendRouteError(reply, error);
|
||
}
|
||
});
|
||
|
||
app.get("/v1/responses/:response_id/input_items", async (request, reply) => {
|
||
try {
|
||
const id = responseIdFromRequest(request);
|
||
const stored = await store.load(id);
|
||
if (!stored) throw responseNotFound(id);
|
||
const query = inputItemsQuerySchema.parse(request.query);
|
||
const ordered = query.order === "asc"
|
||
? [...stored.input_items]
|
||
: [...stored.input_items].reverse();
|
||
const afterIndex = query.after ? ordered.findIndex((item) => item.id === query.after) : -1;
|
||
const available = afterIndex >= 0 ? ordered.slice(afterIndex + 1) : ordered;
|
||
const data = available.slice(0, query.limit);
|
||
return reply.send({
|
||
object: "list",
|
||
data,
|
||
first_id: data[0]?.id ?? null,
|
||
last_id: data.at(-1)?.id ?? null,
|
||
has_more: available.length > data.length,
|
||
});
|
||
} catch (error) {
|
||
return sendRouteError(reply, error);
|
||
}
|
||
});
|
||
|
||
app.post("/v1/responses", async (request, reply) => {
|
||
if (coordinator.busy) return sendBusy(reply);
|
||
if (!installation.installed) {
|
||
return reply.status(503).send(openAIError(
|
||
"Command Code CLI is not installed",
|
||
"command_code_not_installed",
|
||
"server_error",
|
||
));
|
||
}
|
||
if (!installation.authenticated) {
|
||
return reply.status(503).send(openAIError(
|
||
"Command Code is not authenticated; run command-code login",
|
||
"command_code_not_authenticated",
|
||
"server_error",
|
||
));
|
||
}
|
||
|
||
let parsed: ResponseRequest;
|
||
let validation: StructuredValidation | undefined;
|
||
try {
|
||
parsed = parseResponseRequest(request.body);
|
||
validation = createStructuredValidation(parsed.text.format);
|
||
} catch (error) {
|
||
return sendRouteError(reply, error);
|
||
}
|
||
|
||
const model = config.models[parsed.model];
|
||
if (!model) {
|
||
return reply.status(404).send(openAIError(
|
||
`Model '${parsed.model}' is not configured`,
|
||
"model_not_found",
|
||
"invalid_request_error",
|
||
"model",
|
||
));
|
||
}
|
||
|
||
const abortController = coordinator.begin();
|
||
if (!abortController) return sendBusy(reply);
|
||
|
||
let responseCompleted = false;
|
||
const cancelOnDisconnect = () => {
|
||
if (!responseCompleted) abortController.abort("client disconnected");
|
||
};
|
||
request.raw.once("aborted", cancelOnDisconnect);
|
||
reply.raw.once("close", cancelOnDisconnect);
|
||
|
||
const inputItems = normalizeInput(parsed.input);
|
||
const responseId = newResponseId();
|
||
const messageId = newMessageId();
|
||
const createdAt = Math.floor(Date.now() / 1000);
|
||
const effectiveEffort = parsed.reasoning?.effort ?? model.effort;
|
||
const initialResponse = createResponse({
|
||
id: responseId,
|
||
messageId,
|
||
createdAt,
|
||
request: parsed,
|
||
effort: effectiveEffort,
|
||
status: "in_progress",
|
||
});
|
||
|
||
let writer: ResponsesSseWriter | undefined;
|
||
try {
|
||
const chain = parsed.previous_response_id
|
||
? await store.loadChain(parsed.previous_response_id)
|
||
: [];
|
||
const prompt = buildResponsesPrompt(parsed, chain, inputItems);
|
||
const deadline = Date.now() + config.timeout_seconds * 1000;
|
||
|
||
if (parsed.stream) {
|
||
writer = startSse(reply, messageId);
|
||
writer.begin(initialResponse);
|
||
}
|
||
|
||
const execution = await executeResponse({
|
||
config,
|
||
cliModel: model.cli_model,
|
||
effort: effectiveEffort,
|
||
prompt,
|
||
format: parsed.text.format,
|
||
signal: abortController.signal,
|
||
deadline,
|
||
...(validation ? { validation } : {}),
|
||
...(writer ? { writer } : {}),
|
||
});
|
||
|
||
const terminalResponse = createResponse({
|
||
id: responseId,
|
||
messageId,
|
||
createdAt,
|
||
request: parsed,
|
||
effort: effectiveEffort,
|
||
status: execution.validationErrors ? "incomplete" : "completed",
|
||
finalText: execution.finalText,
|
||
...(execution.usage ? { usage: execution.usage } : {}),
|
||
...(execution.validationErrors ? { incompleteReason: "structured_output_validation_failed" } : {}),
|
||
});
|
||
|
||
if (parsed.store) await store.save({ response: terminalResponse, input_items: inputItems });
|
||
responseCompleted = true;
|
||
|
||
if (writer) {
|
||
if (validation) writer.addText(execution.chunks);
|
||
writer.finish(
|
||
terminalResponse,
|
||
terminalResponse.status === "completed" ? "response.completed" : "response.incomplete",
|
||
);
|
||
writer.end();
|
||
return reply;
|
||
}
|
||
return reply.send(terminalResponse);
|
||
} catch (error) {
|
||
responseCompleted = true;
|
||
const terminal = terminalResponseForError({
|
||
error,
|
||
id: responseId,
|
||
messageId,
|
||
createdAt,
|
||
request: parsed,
|
||
effort: effectiveEffort,
|
||
});
|
||
|
||
try {
|
||
if (parsed.store && terminal.response) {
|
||
await store.save({ response: terminal.response, input_items: inputItems });
|
||
}
|
||
} catch (storeError) {
|
||
process.stderr.write(`${formatUnknownError(storeError)}\n`);
|
||
}
|
||
|
||
if (writer) {
|
||
if (terminal.response?.status === "incomplete") {
|
||
writer.finish(terminal.response, "response.incomplete");
|
||
} else if (terminal.response?.status === "failed") {
|
||
writer.failed(terminal.response);
|
||
} else {
|
||
writer.error(terminal.code, terminal.message, terminal.param);
|
||
}
|
||
writer.end();
|
||
return reply;
|
||
}
|
||
|
||
if (terminal.response) return reply.status(terminal.statusCode).send(terminal.response);
|
||
return reply.status(terminal.statusCode).send(openAIError(
|
||
terminal.message,
|
||
terminal.code,
|
||
terminal.type,
|
||
terminal.param,
|
||
));
|
||
} finally {
|
||
request.raw.off("aborted", cancelOnDisconnect);
|
||
reply.raw.off("close", cancelOnDisconnect);
|
||
coordinator.finish(abortController);
|
||
}
|
||
});
|
||
}
|
||
|
||
const inputItemsQuerySchema = z.object({
|
||
after: z.string().optional(),
|
||
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||
}).strict();
|
||
|
||
const emptyQuerySchema = z.object({}).strict();
|
||
|
||
function parseResponseRequest(body: unknown): ResponseRequest {
|
||
const unsupported = findUnsupportedParameter(body);
|
||
if (unsupported) {
|
||
throw new ResponseApiError(
|
||
`Unsupported parameter: '${unsupported}'`,
|
||
400,
|
||
"unsupported_parameter",
|
||
"invalid_request_error",
|
||
unsupported,
|
||
);
|
||
}
|
||
|
||
try {
|
||
return responseRequestSchema.parse(body);
|
||
} catch (error) {
|
||
if (!(error instanceof ZodError)) throw error;
|
||
const unknown = error.issues.find((issue) => issue.code === "unrecognized_keys");
|
||
if (unknown?.code === "unrecognized_keys") {
|
||
const key = unknown.keys[0] ?? "unknown";
|
||
const param = [...unknown.path, key].join(".");
|
||
throw new ResponseApiError(
|
||
`Unsupported parameter: '${param}'`,
|
||
400,
|
||
"unsupported_parameter",
|
||
"invalid_request_error",
|
||
param,
|
||
);
|
||
}
|
||
const message = error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
||
throw new ResponseApiError(message, 400, "invalid_request");
|
||
}
|
||
}
|
||
|
||
function findUnsupportedParameter(body: unknown): string | undefined {
|
||
if (!isRecord(body)) return undefined;
|
||
const supported = new Set([
|
||
"model", "input", "instructions", "stream", "store",
|
||
"previous_response_id", "metadata", "reasoning", "text",
|
||
]);
|
||
const unknownTopLevel = Object.keys(body).find((key) => !supported.has(key));
|
||
if (unknownTopLevel) return unknownTopLevel;
|
||
|
||
if (isRecord(body.text) && isRecord(body.text.format)) {
|
||
const formatType = body.text.format.type;
|
||
if (formatType !== "text" && formatType !== "json_object" && formatType !== "json_schema") {
|
||
return "text.format.type";
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray(body.input)) return undefined;
|
||
for (const [itemIndex, item] of body.input.entries()) {
|
||
if (!isRecord(item)) continue;
|
||
if (item.type !== undefined && item.type !== "message") return `input.${itemIndex}.type`;
|
||
if (!Array.isArray(item.content)) continue;
|
||
for (const [partIndex, part] of item.content.entries()) {
|
||
if (!isRecord(part)) continue;
|
||
if (part.type !== "input_text" && part.type !== "output_text") {
|
||
return `input.${itemIndex}.content.${partIndex}.type`;
|
||
}
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] {
|
||
if (typeof input === "string") {
|
||
return [{
|
||
id: newMessageId(),
|
||
type: "message",
|
||
role: "user",
|
||
content: [{ type: "input_text", text: input }],
|
||
}];
|
||
}
|
||
|
||
return input.map((message) => ({
|
||
id: newMessageId(),
|
||
type: "message",
|
||
role: message.role,
|
||
content: typeof message.content === "string"
|
||
? [{ type: message.role === "assistant" ? "output_text" : "input_text", text: message.content }]
|
||
: message.content.map((part) => ({ type: part.type, text: part.text })),
|
||
}));
|
||
}
|
||
|
||
function buildResponsesPrompt(
|
||
request: ResponseRequest,
|
||
chain: StoredResponse[],
|
||
currentInput: ResponseInputItem[],
|
||
): string {
|
||
const messages: PromptMessage[] = [];
|
||
for (const stored of chain) {
|
||
messages.push(...stored.input_items.map(inputItemToPromptMessage));
|
||
if (stored.response.output_text !== "") {
|
||
messages.push({ role: "assistant", content: stored.response.output_text });
|
||
}
|
||
}
|
||
messages.push(...currentInput.map(inputItemToPromptMessage));
|
||
|
||
const envelope = {
|
||
protocol: "openai-responses-text-history-v1",
|
||
instructions: request.instructions,
|
||
messages,
|
||
};
|
||
|
||
return [
|
||
"下面 JSON 对象是外部客户端提交的完整 Responses 文本任务和本地重建的响应链。",
|
||
"按 system、developer、user、assistant 的角色与顺序理解上下文;当前 instructions 是本次请求的高优先级开发者指令。",
|
||
"严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。",
|
||
formatDirective(request.text.format),
|
||
"JSON 数据开始:",
|
||
JSON.stringify(envelope),
|
||
].join("\n\n");
|
||
}
|
||
|
||
function buildRepairPrompt(
|
||
originalPrompt: string,
|
||
format: ResponseTextFormat,
|
||
invalidOutput: string,
|
||
validationErrors: string[],
|
||
): string {
|
||
return [
|
||
"这是同一 Responses 请求的唯一一次结构化输出修复步骤。",
|
||
"保留原始答案的语义,只修复 JSON 语法和 Schema 违规;只返回修复后的 JSON,禁止代码围栏和解释。",
|
||
formatDirective(format),
|
||
"校验错误:",
|
||
JSON.stringify(validationErrors),
|
||
"不合格输出:",
|
||
JSON.stringify(invalidOutput),
|
||
"原始任务:",
|
||
originalPrompt,
|
||
].join("\n\n");
|
||
}
|
||
|
||
function formatDirective(format: ResponseTextFormat): string {
|
||
if (format.type === "text") return "内部输出格式要求:返回纯文本。";
|
||
if (format.type === "json_object") {
|
||
return "内部输出格式要求:只返回一个合法 JSON 对象;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。";
|
||
}
|
||
return [
|
||
`内部输出格式要求:只返回一个符合 JSON Schema '${format.name}' 的合法 JSON 值;禁止 Markdown 代码围栏、前后说明和非 JSON 文本。`,
|
||
`JSON Schema:${JSON.stringify(format.schema)}`,
|
||
].join("\n");
|
||
}
|
||
|
||
async function executeResponse(options: {
|
||
config: BridgeConfig;
|
||
cliModel: string;
|
||
effort: string;
|
||
prompt: string;
|
||
format: ResponseTextFormat;
|
||
validation?: StructuredValidation;
|
||
signal: AbortSignal;
|
||
deadline: number;
|
||
writer?: ResponsesSseWriter;
|
||
}): Promise<ExecutionResult> {
|
||
const first = await executeAttempt(options, options.prompt, options.validation ? undefined : options.writer);
|
||
if (!options.validation) {
|
||
return {
|
||
finalText: first.result.finalText,
|
||
chunks: first.finalChunks,
|
||
...(first.result.usage ? { usage: first.result.usage } : {}),
|
||
};
|
||
}
|
||
|
||
const firstValidation = options.validation.validate(first.result.finalText);
|
||
if (firstValidation.valid) {
|
||
return {
|
||
finalText: first.result.finalText,
|
||
chunks: first.finalChunks,
|
||
...(first.result.usage ? { usage: first.result.usage } : {}),
|
||
};
|
||
}
|
||
|
||
const repairPrompt = buildRepairPrompt(
|
||
options.prompt,
|
||
options.format,
|
||
first.result.finalText,
|
||
firstValidation.errors,
|
||
);
|
||
const second = await executeAttempt(options, repairPrompt);
|
||
const combinedUsage = addUsage(first.result.usage, second.result.usage);
|
||
const secondValidation = options.validation.validate(second.result.finalText);
|
||
|
||
return {
|
||
finalText: second.result.finalText,
|
||
chunks: second.finalChunks,
|
||
...(combinedUsage ? { usage: combinedUsage } : {}),
|
||
...(!secondValidation.valid ? { validationErrors: secondValidation.errors } : {}),
|
||
};
|
||
}
|
||
|
||
async function executeAttempt(
|
||
options: {
|
||
config: BridgeConfig;
|
||
cliModel: string;
|
||
effort: string;
|
||
signal: AbortSignal;
|
||
deadline: number;
|
||
},
|
||
prompt: string,
|
||
writer?: ResponsesSseWriter,
|
||
): Promise<AttemptResult> {
|
||
const accumulator = new FinalTurnAccumulator();
|
||
const result = await runCommandCode(
|
||
options.config,
|
||
options.cliModel,
|
||
options.effort,
|
||
prompt,
|
||
options.signal,
|
||
{
|
||
timeoutMs: options.deadline - Date.now(),
|
||
onEvent: (event) => {
|
||
const chunks = accumulator.event(event);
|
||
if (chunks && writer) writer.addText(chunks);
|
||
},
|
||
},
|
||
);
|
||
return {
|
||
result,
|
||
finalChunks: accumulator.finalChunks ?? [result.finalText],
|
||
};
|
||
}
|
||
|
||
function createStructuredValidation(format: ResponseTextFormat): StructuredValidation | undefined {
|
||
if (format.type === "text") return undefined;
|
||
if (format.type === "json_object") {
|
||
return {
|
||
validate: (text) => {
|
||
try {
|
||
const value: unknown = JSON.parse(text);
|
||
const valid = isRecord(value);
|
||
return valid
|
||
? { valid: true, errors: [] }
|
||
: { valid: false, errors: ["Output must be a JSON object"] };
|
||
} catch (error) {
|
||
return { valid: false, errors: [`Invalid JSON: ${formatUnknownError(error)}`] };
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
let validator: ValidateFunction;
|
||
try {
|
||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||
validator = ajv.compile(format.schema);
|
||
} catch (error) {
|
||
throw new ResponseApiError(
|
||
`Invalid JSON Schema: ${formatUnknownError(error)}`,
|
||
400,
|
||
"invalid_json_schema",
|
||
"invalid_request_error",
|
||
"text.format.schema",
|
||
);
|
||
}
|
||
|
||
return {
|
||
validate: (text) => {
|
||
let value: unknown;
|
||
try {
|
||
value = JSON.parse(text);
|
||
} catch (error) {
|
||
return { valid: false, errors: [`Invalid JSON: ${formatUnknownError(error)}`] };
|
||
}
|
||
const valid = validator(value);
|
||
return {
|
||
valid,
|
||
errors: valid ? [] : formatAjvErrors(validator.errors),
|
||
};
|
||
},
|
||
};
|
||
}
|
||
|
||
function createResponse(options: {
|
||
id: string;
|
||
messageId: string;
|
||
createdAt: number;
|
||
request: ResponseRequest;
|
||
effort: string;
|
||
status: ResponseStatus;
|
||
finalText?: string;
|
||
usage?: CommandUsage;
|
||
error?: ResponseError;
|
||
incompleteReason?: string;
|
||
}): LocalResponse {
|
||
const finalText = options.finalText ?? "";
|
||
const terminal = options.status !== "in_progress";
|
||
const hasOutput = options.finalText !== undefined;
|
||
const outputStatus = options.status === "completed" ? "completed" : "incomplete";
|
||
return {
|
||
id: options.id,
|
||
object: "response",
|
||
created_at: options.createdAt,
|
||
completed_at: terminal ? Math.floor(Date.now() / 1000) : null,
|
||
status: options.status,
|
||
error: options.error ?? null,
|
||
incomplete_details: options.incompleteReason ? { reason: options.incompleteReason } : null,
|
||
instructions: options.request.instructions,
|
||
model: options.request.model,
|
||
output: hasOutput ? [{
|
||
id: options.messageId,
|
||
type: "message",
|
||
role: "assistant",
|
||
status: outputStatus,
|
||
content: [{ type: "output_text", text: finalText, annotations: [] }],
|
||
}] : [],
|
||
output_text: finalText,
|
||
previous_response_id: options.request.previous_response_id,
|
||
reasoning: { effort: options.effort, summary: null },
|
||
store: options.request.store,
|
||
metadata: options.request.metadata,
|
||
text: { format: options.request.text.format },
|
||
usage: toResponseUsage(options.usage),
|
||
};
|
||
}
|
||
|
||
function terminalResponseForError(options: {
|
||
error: unknown;
|
||
id: string;
|
||
messageId: string;
|
||
createdAt: number;
|
||
request: ResponseRequest;
|
||
effort: string;
|
||
}): {
|
||
response?: LocalResponse;
|
||
statusCode: number;
|
||
code: string;
|
||
message: string;
|
||
type: string;
|
||
param: string | null;
|
||
} {
|
||
const { error } = options;
|
||
if (error instanceof ResponseApiError) {
|
||
return {
|
||
statusCode: error.statusCode,
|
||
code: error.code,
|
||
message: error.message,
|
||
type: error.type,
|
||
param: error.param,
|
||
};
|
||
}
|
||
|
||
if (error instanceof CommandCodeError) {
|
||
if (error.kind === "max_turns" || error.kind === "timeout") {
|
||
const reason = error.kind === "max_turns" ? "max_turns" : "timeout";
|
||
return {
|
||
response: createResponse({
|
||
id: options.id,
|
||
messageId: options.messageId,
|
||
createdAt: options.createdAt,
|
||
request: options.request,
|
||
effort: options.effort,
|
||
status: "incomplete",
|
||
incompleteReason: reason,
|
||
}),
|
||
statusCode: 200,
|
||
code: reason,
|
||
message: error.message,
|
||
type: "server_error",
|
||
param: null,
|
||
};
|
||
}
|
||
|
||
if (error.kind === "cancelled") {
|
||
return {
|
||
response: createResponse({
|
||
id: options.id,
|
||
messageId: options.messageId,
|
||
createdAt: options.createdAt,
|
||
request: options.request,
|
||
effort: options.effort,
|
||
status: "cancelled",
|
||
error: { code: "cancelled", message: "Command Code request cancelled" },
|
||
}),
|
||
statusCode: 499,
|
||
code: "cancelled",
|
||
message: "Command Code request cancelled",
|
||
type: "server_error",
|
||
param: null,
|
||
};
|
||
}
|
||
|
||
const mapped = commandErrorMapping(error.exitCode);
|
||
return {
|
||
response: createResponse({
|
||
id: options.id,
|
||
messageId: options.messageId,
|
||
createdAt: options.createdAt,
|
||
request: options.request,
|
||
effort: options.effort,
|
||
status: "failed",
|
||
error: { code: mapped.code, message: mapped.message },
|
||
}),
|
||
statusCode: mapped.statusCode,
|
||
code: mapped.code,
|
||
message: mapped.message,
|
||
type: "server_error",
|
||
param: null,
|
||
};
|
||
}
|
||
|
||
process.stderr.write(`${formatUnknownError(error)}\n`);
|
||
return {
|
||
statusCode: 500,
|
||
code: "bridge_error",
|
||
message: "Internal bridge error",
|
||
type: "server_error",
|
||
param: null,
|
||
};
|
||
}
|
||
|
||
function commandErrorMapping(exitCode?: number | null): { statusCode: number; code: string; message: string } {
|
||
switch (exitCode) {
|
||
case 3:
|
||
return { statusCode: 503, code: "command_code_not_authenticated", message: "Command Code is not authenticated" };
|
||
case 5:
|
||
return { statusCode: 429, code: "rate_limit_exceeded", message: "Command Code rate limit exceeded" };
|
||
case 10:
|
||
return { statusCode: 402, code: "insufficient_credits", message: "Command Code credits are insufficient" };
|
||
default:
|
||
return { statusCode: 502, code: "command_code_error", message: "Command Code request failed" };
|
||
}
|
||
}
|
||
|
||
function startSse(reply: FastifyReply, messageId: string): ResponsesSseWriter {
|
||
reply.hijack();
|
||
reply.raw.writeHead(200, {
|
||
"Content-Type": "text/event-stream; charset=utf-8",
|
||
"Cache-Control": "no-cache, no-transform",
|
||
Connection: "keep-alive",
|
||
"X-Accel-Buffering": "no",
|
||
});
|
||
reply.raw.flushHeaders();
|
||
return new ResponsesSseWriter(reply.raw, messageId);
|
||
}
|
||
|
||
function sendRouteError(reply: FastifyReply, error: unknown) {
|
||
if (error instanceof ResponseApiError) {
|
||
return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param));
|
||
}
|
||
if (error instanceof ZodError) {
|
||
const unknown = error.issues.find((issue) => issue.code === "unrecognized_keys");
|
||
if (unknown?.code === "unrecognized_keys") {
|
||
const key = unknown.keys[0] ?? "unknown";
|
||
const param = [...unknown.path, key].join(".");
|
||
return reply.status(400).send(openAIError(
|
||
`Unsupported parameter: '${param}'`,
|
||
"unsupported_parameter",
|
||
"invalid_request_error",
|
||
param,
|
||
));
|
||
}
|
||
const message = error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
|
||
return reply.status(400).send(openAIError(message, "invalid_request"));
|
||
}
|
||
process.stderr.write(`${formatUnknownError(error)}\n`);
|
||
return reply.status(500).send(openAIError("Internal bridge error", "bridge_error", "server_error"));
|
||
}
|
||
|
||
function sendBusy(reply: FastifyReply) {
|
||
return reply.status(429).send(openAIError(
|
||
"Another Command Code request is already running",
|
||
"busy",
|
||
"server_error",
|
||
));
|
||
}
|
||
|
||
function responseIdFromRequest(request: FastifyRequest): string {
|
||
const params = request.params as { response_id?: unknown };
|
||
const result = responseIdSchema.safeParse(params.response_id);
|
||
if (!result.success) {
|
||
throw new ResponseApiError("Invalid response_id", 400, "invalid_response_id", "invalid_request_error", "response_id");
|
||
}
|
||
return result.data;
|
||
}
|
||
|
||
function responseNotFound(id: string): ResponseApiError {
|
||
return new ResponseApiError(
|
||
`Response '${id}' was not found`,
|
||
404,
|
||
"response_not_found",
|
||
"invalid_request_error",
|
||
"response_id",
|
||
);
|
||
}
|
||
|
||
function inputItemToPromptMessage(item: ResponseInputItem): PromptMessage {
|
||
return { role: item.role, content: item.content.map((part) => part.text).join("") };
|
||
}
|
||
|
||
function toResponseUsage(usage?: CommandUsage): ResponseUsage | null {
|
||
if (!usage || (usage.inputTokens === undefined && usage.outputTokens === undefined)) return null;
|
||
const inputTokens = usage.inputTokens ?? 0;
|
||
const outputTokens = usage.outputTokens ?? 0;
|
||
return {
|
||
input_tokens: inputTokens,
|
||
output_tokens: outputTokens,
|
||
total_tokens: inputTokens + outputTokens,
|
||
};
|
||
}
|
||
|
||
function addUsage(left?: CommandUsage, right?: CommandUsage): CommandUsage | undefined {
|
||
if (!left && !right) return undefined;
|
||
return {
|
||
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
||
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
|
||
};
|
||
}
|
||
|
||
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
|
||
if (!errors || errors.length === 0) return ["JSON Schema validation failed"];
|
||
return errors.map((error) => `${error.instancePath || "/"}: ${error.message ?? error.keyword}`);
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function newResponseId(): string {
|
||
return `resp_local_${randomHexId()}`;
|
||
}
|
||
|
||
function newMessageId(): string {
|
||
return `msg_local_${randomHexId()}`;
|
||
}
|
||
|
||
function randomHexId(): string {
|
||
return randomUUID().replaceAll("-", "");
|
||
}
|
||
|
||
function formatUnknownError(error: unknown): string {
|
||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||
}
|