feat: add Makelore Robot hardware module
This commit is contained in:
541
electron/api/routes/ai-hardware.ts
Normal file
541
electron/api/routes/ai-hardware.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
|
||||
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const LOCAL_ROOT = '/api/works/ai-hardware';
|
||||
const UPSTREAM_ROOT = '/api/ai-hardware';
|
||||
const STRONG_REVISION = /^"(0|[1-9]\d*)"$/;
|
||||
const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
|
||||
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const MAX_RETRY_AFTER_SECONDS = 2;
|
||||
|
||||
type TokenGetter = typeof getValidWorksSquareAccessToken;
|
||||
|
||||
export type AiHardwareRouteDependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: TokenGetter;
|
||||
apiBaseUrl?: string;
|
||||
randomUuid?: () => string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
class SafeRouteError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asRequiredString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asRevision(value: unknown): number | null {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null;
|
||||
}
|
||||
|
||||
function projectAgent(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = asRequiredString(value.id);
|
||||
const name = asRequiredString(value.name);
|
||||
const configRevision = asRevision(value.config_revision);
|
||||
return id && name && configRevision !== null
|
||||
? { id, name, config_revision: configRevision }
|
||||
: null;
|
||||
}
|
||||
|
||||
function projectDevice(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = asRequiredString(value.id);
|
||||
const agentId = asRequiredString(value.agent_id);
|
||||
const assignmentRevision = asRevision(value.assignment_revision);
|
||||
return id && agentId && assignmentRevision !== null
|
||||
? { id, agent_id: agentId, assignment_revision: assignmentRevision }
|
||||
: null;
|
||||
}
|
||||
|
||||
const CONFIG_STRING_FIELDS = [
|
||||
'system_prompt', 'lang_code', 'language', 'asr_model_id', 'vad_model_id', 'llm_model_id',
|
||||
'slm_model_id', 'vllm_model_id', 'tts_model_id', 'tts_voice_id', 'tts_language',
|
||||
'mem_model_id', 'intent_model_id',
|
||||
] as const;
|
||||
const CONFIG_NUMBER_FIELDS = ['tts_volume', 'tts_rate', 'tts_pitch'] as const;
|
||||
const CONFIG_STRING_LIMITS: Record<string, number> = {
|
||||
system_prompt: 16000, lang_code: 10, language: 10, asr_model_id: 32, vad_model_id: 64,
|
||||
llm_model_id: 32, slm_model_id: 255, vllm_model_id: 32, tts_model_id: 32,
|
||||
tts_voice_id: 32, tts_language: 50, mem_model_id: 32, intent_model_id: 32,
|
||||
};
|
||||
const CLEARABLE_CONFIG_FIELDS = new Set<string>([
|
||||
...CONFIG_STRING_FIELDS, ...CONFIG_NUMBER_FIELDS,
|
||||
]);
|
||||
const CONFIG_UPDATE_FIELDS = new Set<string>([
|
||||
'agent_name', ...CONFIG_STRING_FIELDS, ...CONFIG_NUMBER_FIELDS, 'chat_history_conf', 'clear_fields',
|
||||
]);
|
||||
|
||||
function projectAgentConfig(value: unknown): Record<string, unknown> | null {
|
||||
const agent = projectAgent(value);
|
||||
if (!agent || !isRecord(value)) return null;
|
||||
const result: Record<string, unknown> = { ...agent };
|
||||
for (const field of CONFIG_STRING_FIELDS) {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue !== null && typeof fieldValue !== 'string') return null;
|
||||
result[field] = fieldValue;
|
||||
}
|
||||
for (const field of CONFIG_NUMBER_FIELDS) {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue !== null && (!Number.isInteger(fieldValue) || (fieldValue as number) < -100 || (fieldValue as number) > 100)) return null;
|
||||
result[field] = fieldValue;
|
||||
}
|
||||
const chatHistory = value.chat_history_conf;
|
||||
if (chatHistory !== null && (!Number.isInteger(chatHistory) || (chatHistory as number) < 0 || (chatHistory as number) > 2)) return null;
|
||||
result.chat_history_conf = chatHistory;
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectOverview(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.agents) || !Array.isArray(value.devices)) return null;
|
||||
const allowedStatuses = new Set([
|
||||
'unprovisioned', 'provisioning', 'active', 'credential_recovery_required', 'invalid',
|
||||
]);
|
||||
if (typeof value.status !== 'string' || !allowedStatuses.has(value.status)) return null;
|
||||
const agents = value.agents.map(projectAgent);
|
||||
const devices = value.devices.map(projectDevice);
|
||||
if (agents.some((item) => item === null) || devices.some((item) => item === null)) return null;
|
||||
return { status: value.status, agents, devices };
|
||||
}
|
||||
|
||||
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) {
|
||||
throw new SafeRouteError(413, 'AI_HARDWARE_REQUEST_TOO_LARGE', 'AI hardware request is too large');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.byteLength;
|
||||
if (size > MAX_REQUEST_BYTES) {
|
||||
throw new SafeRouteError(413, 'AI_HARDWARE_REQUEST_TOO_LARGE', 'AI hardware request is too large');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
if (!isRecord(value)) throw new Error('not an object');
|
||||
return value;
|
||||
} catch {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponse(response: Response): Promise<unknown> {
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new SafeRouteError(502, 'AI_HARDWARE_RESPONSE_TOO_LARGE', 'AI hardware service returned too much data', true);
|
||||
}
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new SafeRouteError(502, 'AI_HARDWARE_RESPONSE_TOO_LARGE', 'AI hardware service returned too much data', true);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const text = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8').trim();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExactKeys(body: Record<string, unknown>, allowed: Set<string>): void {
|
||||
if (Object.keys(body).some((key) => !allowed.has(key))) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
}
|
||||
|
||||
function requireLocalId(value: unknown): string {
|
||||
const id = asRequiredString(value);
|
||||
if (!id || !LOCAL_ID.test(id)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function decodeLocalId(value: string): string {
|
||||
try {
|
||||
return requireLocalId(decodeURIComponent(value));
|
||||
} catch (error) {
|
||||
if (error instanceof SafeRouteError) throw error;
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
}
|
||||
|
||||
function requireRevision(body: Record<string, unknown>): number {
|
||||
const revision = asRevision(body.revision);
|
||||
if (revision === null) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
function takeOperationId(body: Record<string, unknown>, createUuid: () => string): string {
|
||||
const candidate = body.client_operation_id ?? createUuid();
|
||||
if (typeof candidate !== 'string' || !OPERATION_ID.test(candidate)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
delete body.client_operation_id;
|
||||
return candidate.toLowerCase();
|
||||
}
|
||||
|
||||
function validateConfigUpdate(body: Record<string, unknown>): Record<string, unknown> {
|
||||
ensureExactKeys(body, new Set(['revision', ...CONFIG_UPDATE_FIELDS]));
|
||||
requireRevision(body);
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (key === 'revision') continue;
|
||||
if (key === 'agent_name') {
|
||||
const name = asRequiredString(value);
|
||||
if (!name || name.length > 64) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
output[key] = name;
|
||||
} else if (key === 'clear_fields') {
|
||||
if (!Array.isArray(value) || value.length > 16 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !CLEARABLE_CONFIG_FIELDS.has(item))) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
output[key] = value;
|
||||
} else if (CONFIG_NUMBER_FIELDS.includes(key as typeof CONFIG_NUMBER_FIELDS[number])) {
|
||||
if (!Number.isInteger(value) || (value as number) < -100 || (value as number) > 100) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
output[key] = value;
|
||||
} else if (key === 'chat_history_conf') {
|
||||
if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 2) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
output[key] = value;
|
||||
} else {
|
||||
const limit = CONFIG_STRING_LIMITS[key];
|
||||
if (typeof value !== 'string' || !value.trim() || limit === undefined || value.length > limit) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
output[key] = value;
|
||||
}
|
||||
}
|
||||
const clears = Array.isArray(output.clear_fields) ? new Set(output.clear_fields) : new Set();
|
||||
if (Object.keys(output).some((key) => key !== 'clear_fields' && clears.has(key))) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
if (!Object.keys(output).length || (Object.keys(output).length === 1 && Array.isArray(output.clear_fields) && output.clear_fields.length === 0)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function strongRevisionFrom(response: Response): number {
|
||||
const etag = response.headers.get('etag');
|
||||
const match = etag?.match(STRONG_REVISION);
|
||||
if (!match) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_ETAG', 'AI hardware service returned an invalid revision', true);
|
||||
const revision = Number(match[1]);
|
||||
if (!Number.isSafeInteger(revision)) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_ETAG', 'AI hardware service returned an invalid revision', true);
|
||||
return revision;
|
||||
}
|
||||
|
||||
function isJsonContentType(response: Response): boolean {
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase().split(';', 1)[0].trim();
|
||||
return contentType === 'application/json' || contentType === 'application/problem+json';
|
||||
}
|
||||
|
||||
function safeErrorForStatus(status: number): { code: string; error: string; retryable: boolean } {
|
||||
if (status === 401) return { code: 'AI_HARDWARE_AUTH_REQUIRED', error: 'Works Square sign-in is required', retryable: false };
|
||||
if (status === 403) return { code: 'AI_HARDWARE_FORBIDDEN', error: 'AI hardware operation is not allowed', retryable: false };
|
||||
if (status === 404) return { code: 'AI_HARDWARE_RESOURCE_NOT_FOUND', error: 'AI hardware resource was not found', retryable: false };
|
||||
if (status === 409 || status === 412) return { code: 'AI_HARDWARE_REVISION_CONFLICT', error: 'AI hardware data changed; refresh and retry', retryable: false };
|
||||
if (status === 429) return { code: 'AI_HARDWARE_RATE_LIMITED', error: 'AI hardware service is busy; retry later', retryable: true };
|
||||
if (status >= 500) return { code: 'AI_HARDWARE_UNAVAILABLE', error: 'AI hardware service is temporarily unavailable', retryable: true };
|
||||
return { code: 'AI_HARDWARE_REQUEST_FAILED', error: 'AI hardware request was rejected', retryable: false };
|
||||
}
|
||||
|
||||
const SAFE_UPSTREAM_ERRORS: Record<string, string> = {
|
||||
ai_hardware_idempotency_conflict: 'AI hardware request conflicts with an earlier operation',
|
||||
ai_hardware_operation_in_progress: 'AI hardware operation is still in progress',
|
||||
ai_hardware_device_already_bound: 'AI hardware device is already bound',
|
||||
ai_hardware_revision_conflict: 'AI hardware data changed; refresh and retry',
|
||||
ai_hardware_state_conflict: 'AI hardware operation conflicts with the current state',
|
||||
ai_hardware_revision_required: 'A current AI hardware revision is required',
|
||||
ai_hardware_activation_code_invalid: 'AI hardware activation code is invalid',
|
||||
ai_hardware_request_rejected: 'AI hardware request was rejected',
|
||||
ai_hardware_resource_not_found: 'AI hardware resource was not found',
|
||||
ai_hardware_provider_state_conflict: 'AI hardware provider state is inconsistent',
|
||||
ai_hardware_credential_recovery_required: 'AI hardware credential recovery is required',
|
||||
ai_hardware_credential_unavailable: 'AI hardware credential is unavailable',
|
||||
ai_hardware_credential_recovery_unavailable: 'AI hardware credential recovery is not currently available',
|
||||
ai_hardware_unconfigured: 'AI hardware integration is not configured',
|
||||
ai_hardware_disabled: 'AI hardware module is not enabled',
|
||||
ai_hardware_idempotency_key_invalid: 'AI hardware operation identity is invalid',
|
||||
ai_hardware_revision_invalid: 'AI hardware revision is invalid',
|
||||
xiaozhi_hardware_protocol_error: 'AI hardware service returned an invalid response',
|
||||
xiaozhi_hardware_timeout: 'AI hardware service timed out',
|
||||
xiaozhi_hardware_unavailable: 'AI hardware service is temporarily unavailable',
|
||||
};
|
||||
|
||||
function safeUpstreamError(payload: unknown, status: number): { code: string; error: string; retryable: boolean } {
|
||||
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
|
||||
const code = detail && typeof detail.error_code === 'string' && SAFE_UPSTREAM_ERRORS[detail.error_code]
|
||||
? detail.error_code
|
||||
: null;
|
||||
if (!code) return safeErrorForStatus(status);
|
||||
return { code, error: SAFE_UPSTREAM_ERRORS[code], retryable: detail?.retryable === true };
|
||||
}
|
||||
|
||||
function sendFailure(
|
||||
res: ServerResponse,
|
||||
localStatus: number,
|
||||
status: number,
|
||||
code: string,
|
||||
error: string,
|
||||
retryable: boolean,
|
||||
retryAfterSeconds?: number,
|
||||
operationId?: string,
|
||||
): void {
|
||||
sendJson(res, localStatus, {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }),
|
||||
...(operationId === undefined ? {} : { operation_id: operationId }),
|
||||
});
|
||||
}
|
||||
|
||||
function retryAfterSeconds(response: Response, cap = 86_400): number | undefined {
|
||||
const raw = response.headers.get('retry-after')?.trim();
|
||||
if (!raw) return undefined;
|
||||
const delta = /^\d+$/.test(raw) ? Number(raw) : Math.ceil((Date.parse(raw) - Date.now()) / 1000);
|
||||
if (!Number.isFinite(delta) || delta < 0) return undefined;
|
||||
return Math.min(delta, cap);
|
||||
}
|
||||
|
||||
export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDependencies = {}) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
const createUuid = dependencies.randomUuid ?? randomUUID;
|
||||
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return async function handleAiHardwareRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname !== LOCAL_ROOT && !url.pathname.startsWith(`${LOCAL_ROOT}/`)) return false;
|
||||
|
||||
let operationId: string | undefined;
|
||||
try {
|
||||
let upstreamPath: string;
|
||||
let body: Record<string, unknown> | undefined;
|
||||
let project: (value: unknown) => Record<string, unknown> | null;
|
||||
let requireEtag = false;
|
||||
let ifMatch: number | undefined;
|
||||
let expectedStatus: number;
|
||||
const method = req.method ?? 'GET';
|
||||
|
||||
if (method === 'GET' && url.pathname === LOCAL_ROOT) {
|
||||
upstreamPath = UPSTREAM_ROOT;
|
||||
project = projectOverview;
|
||||
expectedStatus = 200;
|
||||
} else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/credential-recovery`) {
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['client_operation_id']));
|
||||
operationId = takeOperationId(input, createUuid);
|
||||
upstreamPath = `${UPSTREAM_ROOT}/credential-recovery`;
|
||||
project = projectOverview;
|
||||
expectedStatus = 200;
|
||||
} else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/agents`) {
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['agent_name', 'client_operation_id']));
|
||||
operationId = takeOperationId(input, createUuid);
|
||||
const agentName = asRequiredString(input.agent_name);
|
||||
if (!agentName || agentName.length > 64) throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
upstreamPath = `${UPSTREAM_ROOT}/agents`;
|
||||
body = { agent_name: agentName };
|
||||
project = projectAgent;
|
||||
expectedStatus = 201;
|
||||
} else if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/device-bindings`) {
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['activation_code', 'agent_id', 'client_operation_id']));
|
||||
operationId = takeOperationId(input, createUuid);
|
||||
if (typeof input.activation_code !== 'string' || !/^\d{6}$/.test(input.activation_code)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
upstreamPath = `${UPSTREAM_ROOT}/device-bindings`;
|
||||
body = { activation_code: input.activation_code, agent_id: requireLocalId(input.agent_id) };
|
||||
project = projectDevice;
|
||||
expectedStatus = 201;
|
||||
} else {
|
||||
const agentMatch = url.pathname.match(/^\/api\/works\/ai-hardware\/agents\/([^/]+)$/);
|
||||
const assignmentMatch = url.pathname.match(/^\/api\/works\/ai-hardware\/devices\/([^/]+)\/agent-assignment$/);
|
||||
if (agentMatch && (method === 'GET' || method === 'PATCH')) {
|
||||
const id = decodeLocalId(agentMatch[1]);
|
||||
upstreamPath = `/api/ai-hardware/agents/${encodeURIComponent(id)}`;
|
||||
project = projectAgentConfig;
|
||||
requireEtag = true;
|
||||
expectedStatus = 200;
|
||||
if (method === 'PATCH') {
|
||||
const input = await readBoundedJson(req);
|
||||
operationId = takeOperationId(input, createUuid);
|
||||
ifMatch = requireRevision(input);
|
||||
body = validateConfigUpdate(input);
|
||||
}
|
||||
} else if (assignmentMatch && (method === 'GET' || method === 'PUT')) {
|
||||
const id = decodeLocalId(assignmentMatch[1]);
|
||||
upstreamPath = `/api/ai-hardware/devices/${encodeURIComponent(id)}/agent-assignment`;
|
||||
project = projectDevice;
|
||||
requireEtag = true;
|
||||
expectedStatus = 200;
|
||||
if (method === 'PUT') {
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['revision', 'agent_id', 'client_operation_id']));
|
||||
operationId = takeOperationId(input, createUuid);
|
||||
ifMatch = requireRevision(input);
|
||||
body = { agent_id: requireLocalId(input.agent_id) };
|
||||
}
|
||||
} else {
|
||||
sendFailure(res, 404, 404, 'AI_HARDWARE_ROUTE_NOT_FOUND', 'AI hardware route was not found', false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
sendFailure(res, 200, 401, 'AI_HARDWARE_AUTH_REQUIRED', 'Works Square sign-in is required', false);
|
||||
return true;
|
||||
}
|
||||
const idempotencyKey = operationId ? `makelore-${operationId}` : undefined;
|
||||
const call = async (accessToken: string): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetchImpl(`${apiBaseUrl}${upstreamPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
...(ifMatch === undefined ? {} : { 'If-Match': `"${ifMatch}"` }),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new SafeRouteError(504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
let currentToken = token;
|
||||
let response = await call(currentToken);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) {
|
||||
currentToken = refreshed;
|
||||
response = await call(currentToken);
|
||||
}
|
||||
}
|
||||
|
||||
let cachedErrorPayload: unknown;
|
||||
if (response.status === 409 && operationId) {
|
||||
const retryDelay = retryAfterSeconds(response, MAX_RETRY_AFTER_SECONDS);
|
||||
if (retryDelay !== undefined) {
|
||||
cachedErrorPayload = isJsonContentType(response) ? await readBoundedResponse(response).catch(() => null) : null;
|
||||
const safe = safeUpstreamError(cachedErrorPayload, response.status);
|
||||
if (safe.code === 'ai_hardware_operation_in_progress') {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, retryDelay * 1000));
|
||||
response = await call(currentToken);
|
||||
cachedErrorPayload = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status;
|
||||
if (status === 404 && upstreamPath === UPSTREAM_ROOT) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
sendFailure(res, 200, 404, 'AI_HARDWARE_DISABLED', 'AI hardware module is not enabled', false);
|
||||
return true;
|
||||
}
|
||||
const retryAfter = retryAfterSeconds(response);
|
||||
const payload = cachedErrorPayload ?? (isJsonContentType(response)
|
||||
? await readBoundedResponse(response).catch(() => null)
|
||||
: (await response.body?.cancel().catch(() => undefined), null));
|
||||
const safe = safeUpstreamError(payload, status);
|
||||
sendFailure(
|
||||
res,
|
||||
200,
|
||||
status,
|
||||
safe.code,
|
||||
safe.error,
|
||||
safe.retryable,
|
||||
retryAfter,
|
||||
operationId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.status !== expectedStatus || !isJsonContentType(response)) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||
}
|
||||
const revision = requireEtag ? strongRevisionFrom(response) : undefined;
|
||||
const projected = project(await readBoundedResponse(response));
|
||||
if (!projected) throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||
if (revision !== undefined) {
|
||||
const dtoRevision = 'config_revision' in projected
|
||||
? projected.config_revision
|
||||
: projected.assignment_revision;
|
||||
if (dtoRevision !== revision) {
|
||||
throw new SafeRouteError(502, 'AI_HARDWARE_INVALID_RESPONSE', 'AI hardware service returned an invalid response', true);
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, { success: true, data: projected, ...(revision === undefined ? {} : { revision }) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof SafeRouteError) {
|
||||
sendFailure(res, 200, error.status, error.code, error.message, error.retryable, undefined, operationId);
|
||||
} else if (error instanceof Error && error.name === 'AbortError') {
|
||||
sendFailure(res, 200, 504, 'AI_HARDWARE_TIMEOUT', 'AI hardware service timed out', true, undefined, operationId);
|
||||
} else {
|
||||
sendFailure(res, 200, 502, 'AI_HARDWARE_UNAVAILABLE', 'AI hardware service is temporarily unavailable', true, undefined, operationId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const handleAiHardwareRoutes = createAiHardwareRouteHandler();
|
||||
@@ -4,6 +4,7 @@ import { getPort } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { HostApiContext } from './context';
|
||||
import { handleAiProxyRoutes } from './routes/ai-proxy';
|
||||
import { handleAiHardwareRoutes } from './routes/ai-hardware';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleOpencodeRoutes } from './routes/opencode';
|
||||
@@ -29,6 +30,7 @@ type RouteHandler = (
|
||||
|
||||
const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAiProxyRoutes,
|
||||
handleAiHardwareRoutes,
|
||||
handleAppRoutes,
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
|
||||
@@ -29,7 +29,15 @@ export function registerHostApiProxyHandlers(): void {
|
||||
}
|
||||
|
||||
const method = (request.method || 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = { ...(request.headers || {}) };
|
||||
const protectedHeaders = new Set([
|
||||
'authorization',
|
||||
RENDERER_CAPABILITY_HEADER.toLowerCase(),
|
||||
]);
|
||||
const headers = Object.fromEntries(
|
||||
Object.entries(request.headers || {}).filter(
|
||||
([name]) => !protectedHeaders.has(name.toLowerCase()),
|
||||
),
|
||||
);
|
||||
// Inject the per-session auth token so the Host API server accepts this request.
|
||||
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
|
||||
headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability();
|
||||
|
||||
Reference in New Issue
Block a user