diff --git a/bun.lock b/bun.lock index cc3fcdc..a480727 100644 --- a/bun.lock +++ b/bun.lock @@ -53,7 +53,7 @@ }, "packages/plugins/nexus-service": { "name": "@microvoid/convax-plugin-nexus-service", - "version": "0.3.7", + "version": "0.3.8", "dependencies": { "@microvoid/convax-nexus-mcp": "workspace:*" } @@ -191,7 +191,7 @@ }, "packages/tools/nexus-mcp": { "name": "@microvoid/convax-nexus-mcp", - "version": "0.3.6", + "version": "0.3.7", "bin": { "convax-nexus-mcp": "dist/convax-nexus-mcp" }, diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index 72b9c16..1a74b22 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -320,6 +320,15 @@ it contains `{tool,name}` references to generation tools and may be `[]` for an operation-only Plugin such as FFmpeg. Model names and referenced tools are unique. This positive declaration prevents utilities from appearing as generation models. +A companion whose `tools/list` response exposes a live, bounded model catalog may +annotate exactly one required, top-level string property in that generation tool's +input schema with `"x-convax-role": "generation-model-id"`. The marked property +must contain explicit bounded choices (for example, `oneOf` string constants), so +the host can project those choices directly into its generation-model picker and +bind the selected opaque value at execution time. Omit the annotation when the +companion falls back to a free-text model field; the annotation never turns +unbounded provider input into a trusted catalog. + Outputs are `text`, `image`, `video`, or `audio`. `acceptedInputs` may contain only `reference_image`, `reference_video`, `first_frame`, `last_frame`, `audio`, and `text`. It describes optional Canvas references; the prompt is always a separate diff --git a/packages/plugins/nexus-service/convax-package.json b/packages/plugins/nexus-service/convax-package.json index 3cd791f..43c2e0a 100644 --- a/packages/plugins/nexus-service/convax-package.json +++ b/packages/plugins/nexus-service/convax-package.json @@ -4,7 +4,7 @@ "id": "nexus-service", "name": "Nexus · OpenRouter", "description": "Connects Convax to the Convax Workspace in Nexus for OpenRouter chat and live image-model generation through short-lived Data Tokens.", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "compatibility": { "pluginSchema": "convax.plugin/7", @@ -13,7 +13,7 @@ "companions": [ { "command": "convax-nexus-mcp", - "version": "0.3.6", + "version": "0.3.7", "source": "packages/tools/nexus-mcp", "targets": [ { diff --git a/packages/plugins/nexus-service/package.json b/packages/plugins/nexus-service/package.json index a111bc7..20c509b 100644 --- a/packages/plugins/nexus-service/package.json +++ b/packages/plugins/nexus-service/package.json @@ -1,6 +1,6 @@ { "name": "@microvoid/convax-plugin-nexus-service", - "version": "0.3.7", + "version": "0.3.8", "private": true, "type": "module", "dependencies": { diff --git a/packages/plugins/nexus-service/package/manifest.json b/packages/plugins/nexus-service/package/manifest.json index bb1fbf0..09afe45 100644 --- a/packages/plugins/nexus-service/package/manifest.json +++ b/packages/plugins/nexus-service/package/manifest.json @@ -3,7 +3,7 @@ "id": "nexus-service", "name": "Nexus · OpenRouter", "description": "Connects Convax to the Convax Workspace in Nexus for OpenRouter chat and live image-model generation through short-lived Data Tokens.", - "version": "0.3.7", + "version": "0.3.8", "contributes": { "generation": { "models": [ diff --git a/packages/tools/nexus-mcp/README.md b/packages/tools/nexus-mcp/README.md index b88cd0f..1374d8a 100644 --- a/packages/tools/nexus-mcp/README.md +++ b/packages/tools/nexus-mcp/README.md @@ -8,6 +8,15 @@ image-output models populate the host-rendered Nexus image-model control. Image generation uses the already-metered Chat Completions path and returns only validated embedded image artifacts to the host. Nexus video endpoints remain unavailable until the service adds a dedicated video Usage Inspector and quota settlement model. +When the live image catalog is bounded, the companion marks its model field so a +compatible host can present each image model as a direct choice; its unbounded +free-text fallback remains unmarked. + +Each image request sends the host operation id as `x-nexus-request-id` for safe +diagnostic correlation. A rejected request exposes only its HTTP status, an +allow-listed Gateway error code, and that locally generated operation id to the MCP +caller. Response-provided identifiers, raw upstream messages, response bodies, +prompts, and tokens never cross that diagnostic boundary. The companion owns PKCE and the loopback callback, stores the rotating Nexus refresh grant in a private user file, and exposes only a random loopback Gateway credential to diff --git a/packages/tools/nexus-mcp/package.json b/packages/tools/nexus-mcp/package.json index 87fecd9..5f6e543 100644 --- a/packages/tools/nexus-mcp/package.json +++ b/packages/tools/nexus-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@microvoid/convax-nexus-mcp", - "version": "0.3.6", + "version": "0.3.7", "private": true, "license": "MIT", "type": "module", diff --git a/packages/tools/nexus-mcp/src/image-generator.ts b/packages/tools/nexus-mcp/src/image-generator.ts index 54c0747..0ab7214 100644 --- a/packages/tools/nexus-mcp/src/image-generator.ts +++ b/packages/tools/nexus-mcp/src/image-generator.ts @@ -17,6 +17,7 @@ const maximumTotalImageBytes = 32 * 1024 * 1024; const maximumImages = 8; const maximumTrackedOperations = 256; const modelIdPattern = /^~?[A-Za-z0-9]+(?:[._/:-][A-Za-z0-9]+)*$/u; +const operationIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; interface TrackedOperation { fingerprint: string; @@ -76,6 +77,7 @@ export class NexusImageGenerator { const response = await this.client.imageCompletion( call.model, call.prompt, + call.operation_id, signal, ); if (signal.aborted) throw abortError(); @@ -150,13 +152,17 @@ function parseGenerationCall(value: unknown): GenerationCall { if (Buffer.byteLength(prompt, "utf8") > maximumPromptBytes) { throw new Error("Nexus image prompt is too large"); } + const operationId = trimmedString( + input.operation_id, + "Nexus generation operation id", + 128, + ); + if (!operationIdPattern.test(operationId)) { + throw new Error("Nexus generation operation id is invalid"); + } return { model, - operation_id: trimmedString( - input.operation_id, - "Nexus generation operation id", - 256, - ), + operation_id: operationId, output: "image", output_directory: trimmedString( input.output_directory, diff --git a/packages/tools/nexus-mcp/src/mcp-server.ts b/packages/tools/nexus-mcp/src/mcp-server.ts index d6f1a0f..0e438fe 100644 --- a/packages/tools/nexus-mcp/src/mcp-server.ts +++ b/packages/tools/nexus-mcp/src/mcp-server.ts @@ -1,7 +1,11 @@ import { asRecord, type JsonRpcRequest, type ToolResult } from "./contracts.ts"; import { NexusAuthorization } from "./authorization.ts"; import { NexusCheckoutStore } from "./checkout-store.ts"; -import { NexusClient, type NexusClientOptions } from "./nexus-client.ts"; +import { + NexusClient, + NexusImageHttpError, + type NexusClientOptions, +} from "./nexus-client.ts"; import { NexusImageGenerator } from "./image-generator.ts"; import { NexusLlmGateway } from "./llm-gateway.ts"; import { NexusPluginService } from "./plugin-service.ts"; @@ -17,7 +21,12 @@ const emptyInputSchema = { } as const; const generationCallProperties = { - operation_id: { maxLength: 256, minLength: 1, type: "string" }, + operation_id: { + maxLength: 128, + minLength: 1, + pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + type: "string", + }, output: { const: "image", type: "string" }, output_directory: { maxLength: 4_096, minLength: 1, type: "string" }, prompt: { maxLength: 20_000, minLength: 1, type: "string" }, @@ -34,6 +43,7 @@ export function imageGenerationTool( oneOf: models.map(({ id, name }) => ({ const: id, title: name })), title: "Model", type: "string", + "x-convax-role": "generation-model-id", } : { description: @@ -161,6 +171,28 @@ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { return input.jsonrpc === "2.0" && typeof input.method === "string"; } +export function publicImageGenerationErrorMessage(error: unknown) { + if (!(error instanceof NexusImageHttpError)) { + return "Nexus image generation failed. Check Nexus before retrying because the upstream task result may be unknown."; + } + const details = [ + `HTTP ${error.status}`, + ...(error.code === undefined ? [] : [`code ${error.code}`]), + `request id ${error.requestId}`, + ].join(", "); + const action = + error.status === 401 || error.status === 403 + ? "Reconnect Nexus in Services before trying again." + : error.status === 429 + ? "Check the Nexus quota or Plan before trying again." + : error.code === "metering_unsupported" + ? "This image route is not enabled for Nexus metering; contact Nexus support before trying again." + : error.status >= 500 + ? "Use the request id to review Nexus diagnostics before trying again." + : "Review Nexus Services and choose a currently listed image model before trying again."; + return `Nexus rejected image generation (${details}). ${action}`; +} + export interface NexusMcpServerOptions { checkouts?: NexusCheckoutStore; client?: NexusClientOptions; @@ -278,7 +310,7 @@ export class NexusMcpServer { this.#sendResult(value.id, { capabilities: { tools: {} }, protocolVersion, - serverInfo: { name: "convax-nexus-mcp", version: "0.3.5" }, + serverInfo: { name: "convax-nexus-mcp", version: "0.3.7" }, }); return; } @@ -296,6 +328,7 @@ export class NexusMcpServer { async #callTool(request: JsonRpcRequest & { id: number | string }) { const controller = new AbortController(); this.#inflight.set(request.id, controller); + let toolName: string | undefined; try { const params = asRecord(request.params, "tools/call params"); if ( @@ -305,6 +338,7 @@ export class NexusMcpServer { this.#sendError(request.id, -32_602, "Unknown tool"); return; } + toolName = params.name; const input = asRecord(params.arguments ?? {}, "tool arguments"); if (emptyTools.has(params.name) && Object.keys(input).length !== 0) { this.#sendError( @@ -361,7 +395,7 @@ export class NexusMcpServer { content: [{ text: "Nexus service operation completed.", type: "text" }], structuredContent, } satisfies ToolResult); - } catch { + } catch (error) { const cancelled = controller.signal.aborted; console.error( cancelled ? "[nexus] request cancelled" : "[nexus] request failed", @@ -371,7 +405,9 @@ export class NexusMcpServer { { text: cancelled ? "Nexus request was cancelled." - : "Nexus request failed.", + : toolName === "image.generate" + ? publicImageGenerationErrorMessage(error) + : "Nexus request failed.", type: "text", }, ], diff --git a/packages/tools/nexus-mcp/src/nexus-client.ts b/packages/tools/nexus-mcp/src/nexus-client.ts index c9da573..71f11cf 100644 --- a/packages/tools/nexus-mcp/src/nexus-client.ts +++ b/packages/tools/nexus-mcp/src/nexus-client.ts @@ -20,7 +20,50 @@ const refreshSkewMs = 30_000; const maximumModelCatalogBytes = 8 * 1024 * 1024; const maximumModelCatalogEntries = 2_048; const maximumImageCompletionBytes = 16 * 1024 * 1024; +const maximumImageErrorBytes = 64 * 1024; const openRouterModelIdPattern = /^~?[A-Za-z0-9]+(?:[._/:-][A-Za-z0-9]+)*$/; +const nexusRequestIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const nexusGatewayErrorCodes: ReadonlySet = new Set([ + "access_unavailable", + "invalid_gateway_request", + "invalid_gateway_route", + "invalid_inference_key", + "invalid_usage_request", + "metering_unsupported", + "provider_configuration_error", + "provider_connection_not_found", + "provider_first_byte_timeout", + "provider_overall_timeout", + "provider_unavailable", + "quota_exceeded", + "unsafe_provider_path", + "workspace_access_denied", +]); + +export class NexusImageHttpError extends Error { + override name = "NexusImageHttpError"; + readonly code: string | undefined; + readonly requestId: string; + readonly status: number; + + constructor( + status: number, + requestId: string, + code?: unknown, + ) { + super("Nexus image generation request was rejected"); + if (!Number.isInteger(status) || status < 100 || status > 599) { + throw new Error("Nexus image HTTP diagnostic status is invalid"); + } + const trustedRequestId = validRequestId(requestId); + if (trustedRequestId === undefined) { + throw new Error("Nexus image HTTP diagnostic request id is invalid"); + } + this.status = status; + this.code = validErrorCode(code); + this.requestId = trustedRequestId; + } +} export interface NexusClientOptions { fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -220,8 +263,12 @@ export class NexusClient { async imageCompletion( model: string, prompt: string, + operationId: string, signal: AbortSignal, ): Promise { + if (!nexusRequestIdPattern.test(operationId)) { + throw new Error("Nexus image generation operation id is invalid"); + } const context = await this.gatewayContext(); const response = await this.#fetch( new URL(`${context.provider.gatewayBaseUrl}/chat/completions`), @@ -235,13 +282,20 @@ export class NexusClient { headers: { authorization: `Bearer ${context.dataToken}`, "content-type": "application/json", + "x-nexus-request-id": operationId, }, method: "POST", signal, }, ); - if (!response.ok) - throw new Error(`Nexus image generation failed with HTTP ${response.status}`); + if (!response.ok) { + const error = await parseImageHttpError(response); + throw new NexusImageHttpError( + response.status, + operationId, + error.code, + ); + } const declared = Number(response.headers.get("content-length") ?? 0); if (Number.isFinite(declared) && declared > maximumImageCompletionBytes) { throw new Error("Nexus image generation response is too large"); @@ -438,6 +492,82 @@ export class NexusClient { } } +async function parseImageHttpError(response: Response): Promise<{ code?: string }> { + const serialized = await readBoundedResponseText( + response, + maximumImageErrorBytes, + ).catch(() => undefined); + if (serialized === undefined) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(serialized) as unknown; + } catch { + return {}; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const input = parsed as Record; + const error = + input.error && typeof input.error === "object" && !Array.isArray(input.error) + ? (input.error as Record) + : undefined; + const code = validErrorCode(error?.code); + return code === undefined ? {} : { code }; +} + +async function readBoundedResponseText( + response: Response, + maximumBytes: number, +): Promise { + const declared = Number(response.headers.get("content-length") ?? 0); + if (Number.isFinite(declared) && declared > maximumBytes) { + await response.body?.cancel().catch(() => undefined); + return undefined; + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maximumBytes) { + await reader.cancel().catch(() => undefined); + return undefined; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function validErrorCode(value: unknown) { + return typeof value === "string" && nexusGatewayErrorCodes.has(value) + ? value + : undefined; +} + +function validRequestId(value: unknown) { + return typeof value === "string" && nexusRequestIdPattern.test(value) + ? value + : undefined; +} + function parseHostedAccess(value: unknown): HostedAccess { const input = record(value, "Nexus access response"); const workspace = record(input.workspace, "Nexus Workspace"); diff --git a/packages/tools/nexus-mcp/test/image-generator.test.ts b/packages/tools/nexus-mcp/test/image-generator.test.ts index e5f4e57..adba7a2 100644 --- a/packages/tools/nexus-mcp/test/image-generator.test.ts +++ b/packages/tools/nexus-mcp/test/image-generator.test.ts @@ -31,7 +31,12 @@ describe("NexusImageGenerator", () => { outputModalities: ["image", "text"], }, ], - imageCompletion: async () => { + imageCompletion: async ( + _model: string, + _prompt: string, + operationId: string, + ) => { + expect(operationId).toBe("operation-123"); completions += 1; return { choices: [ diff --git a/packages/tools/nexus-mcp/test/mcp-server.test.ts b/packages/tools/nexus-mcp/test/mcp-server.test.ts index 775674e..d4fa76c 100644 --- a/packages/tools/nexus-mcp/test/mcp-server.test.ts +++ b/packages/tools/nexus-mcp/test/mcp-server.test.ts @@ -1,10 +1,15 @@ import { expect, test } from "bun:test"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { imageGenerationTool, NexusMcpServer, + publicImageGenerationErrorMessage, tools, } from "../src/mcp-server.ts"; +import { NexusImageHttpError } from "../src/nexus-client.ts"; import type { NexusSessionStore } from "../src/session-store.ts"; test("Nexus companion exposes image generation plus the fixed Service and LLM tools", () => { @@ -37,9 +42,39 @@ test("Nexus image generation projects current image models as a bounded select", ], title: "Model", type: "string", + "x-convax-role": "generation-model-id", }); }); +test("Nexus free-text image model fallback is not a trusted model catalog", () => { + const model = imageGenerationTool().inputSchema.properties.model; + expect(model).not.toHaveProperty("x-convax-role"); + expect(model).not.toHaveProperty("oneOf"); +}); + +test("Nexus image diagnostics expose only typed bounded HTTP fields", () => { + expect( + publicImageGenerationErrorMessage( + new NexusImageHttpError( + 409, + "gateway-request-123", + "metering_unsupported", + ), + ), + ).toBe( + "Nexus rejected image generation (HTTP 409, code metering_unsupported, request id gateway-request-123). This image route is not enabled for Nexus metering; contact Nexus support before trying again.", + ); + const generic = publicImageGenerationErrorMessage( + new Error("raw secret-token secret-prompt /private/output/path"), + ); + expect(generic).toBe( + "Nexus image generation failed. Check Nexus before retrying because the upstream task result may be unknown.", + ); + expect(generic).not.toContain("secret-token"); + expect(generic).not.toContain("secret-prompt"); + expect(generic).not.toContain("/private/output/path"); +}); + test("the first tools/list response includes live image model choices", async () => { const responses: unknown[] = []; const sessions = { @@ -132,8 +167,141 @@ test("the first tools/list response includes live image model choices", async () oneOf: [{ const: "openai/gpt-image-1", title: "GPT Image 1" }], title: "Model", type: "string", + "x-convax-role": "generation-model-id", + }); + + await server.shutdown(1_000); + await running; +}); + +test("image.generate returns bounded correlated HTTP diagnostics", async () => { + const outputDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), "convax-nexus-mcp-server-"), + ); + const responses: unknown[] = []; + const sessions = { + async read() { + return { + nexusOrigin: "http://localhost:3000", + refreshToken: "refresh-token-with-sufficient-length", + schema: "convax.nexus-refresh-grant/1", + workspaceSlug: "convax", + }; + }, + async write() {}, + } as unknown as NexusSessionStore; + const server = new NexusMcpServer({ + client: { + fetch: async (input) => { + const request = input instanceof Request ? input : new Request(input); + const url = new URL(request.url); + if (url.pathname.endsWith("/auth/token")) { + return Response.json({ + access_token: "access-token-with-sufficient-length", + data_token: "data-token-with-sufficient-length", + data_token_expires_at: "2026-07-27T08:10:00.000Z", + expires_in: 900, + refresh_token: "rotated-refresh-token-with-sufficient-length", + token_type: "Bearer", + }); + } + if (url.pathname === "/user/v1/provider-connections") { + return Response.json([ + { + gatewayBaseUrl: + "http://localhost:4000/providers/26010000-0000-4000-8000-000000000010", + id: "26010000-0000-4000-8000-000000000010", + name: "OpenRouter", + protocolProfile: "openai-compatible", + status: "ACTIVE", + workspaceId: "26010000-0000-4000-8000-000000000003", + }, + ]); + } + if (url.pathname.endsWith("/models")) { + return Response.json({ + data: [ + { + architecture: { output_modalities: ["image", "text"] }, + id: "openai/gpt-image-1", + name: "GPT Image 1", + }, + ], + }); + } + if (url.pathname.endsWith("/chat/completions")) { + return Response.json( + { + error: { + code: "metering_unsupported", + message: + "raw secret-token secret-prompt /private/output/path", + }, + }, + { + headers: { "x-nexus-request-id": "sk-or-v1-secret-token" }, + status: 409, + }, + ); + } + throw new Error(`Unexpected request: ${url.pathname}`); + }, + now: () => new Date("2026-07-27T08:00:00.000Z"), + }, + send: (value) => responses.push(value), + sessions, + }); + let controller!: ReadableStreamDefaultController; + const input = new ReadableStream({ + start(value) { + controller = value; + }, + }); + const running = server.run(input); + controller.enqueue( + new TextEncoder().encode( + `${JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: { + model: "openai/gpt-image-1", + operation_id: "operation-123", + output: "image", + output_directory: outputDirectory, + prompt: "secret-prompt", + references: [], + schema: "convax.generation-call/1", + }, + name: "image.generate", + }, + })}\n`, + ), + ); + for (let attempt = 0; attempt < 100 && responses.length === 0; attempt += 1) { + await Bun.sleep(10); + } + + expect(responses).toHaveLength(1); + const serialized = JSON.stringify(responses[0]); + expect(responses[0]).toMatchObject({ + result: { + content: [ + { + text: "Nexus rejected image generation (HTTP 409, code metering_unsupported, request id operation-123). This image route is not enabled for Nexus metering; contact Nexus support before trying again.", + type: "text", + }, + ], + isError: true, + }, }); + expect(serialized).not.toContain("secret-token"); + expect(serialized).not.toContain("secret-prompt"); + expect(serialized).not.toContain("sk-or-v1-secret-token"); + expect(serialized).not.toContain("/private/output/path"); await server.shutdown(1_000); await running; + await fs.rm(outputDirectory, { force: true, recursive: true }); }); diff --git a/packages/tools/nexus-mcp/test/nexus-client.test.ts b/packages/tools/nexus-mcp/test/nexus-client.test.ts index bfc2023..66222b4 100644 --- a/packages/tools/nexus-mcp/test/nexus-client.test.ts +++ b/packages/tools/nexus-mcp/test/nexus-client.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { NexusClient } from "../src/nexus-client.ts"; +import { + NexusClient, + NexusImageHttpError, +} from "../src/nexus-client.ts"; import { NexusSessionStore } from "../src/session-store.ts"; const roots: string[] = []; @@ -417,4 +420,171 @@ describe("NexusClient", () => { }, ]); }); + + test("correlates image requests and exposes only bounded HTTP diagnostics", async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "convax-nexus-client-"), + ); + roots.push(root); + const sessions = new NexusSessionStore({ XDG_CONFIG_HOME: root }); + await sessions.write({ + nexusOrigin: "http://localhost:3000", + refreshToken: "original-refresh-token-value", + schema: "convax.nexus-refresh-grant/1", + workspaceSlug: "convax", + }); + const imageRequests: Array<{ + authorization: string | null; + body: unknown; + requestId: string | null; + }> = []; + let imageAttempts = 0; + const client = new NexusClient(sessions, { + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname.endsWith("/auth/token")) { + return Response.json({ + access_token: "fresh-access-token-with-sufficient-length", + data_token: "fresh-data-token-with-sufficient-length", + data_token_expires_at: "2026-07-26T08:10:00.000Z", + expires_in: 900, + refresh_token: "rotated-refresh-token-with-sufficient-length", + token_type: "Bearer", + }); + } + if (url.pathname === "/user/v1/provider-connections") { + return Response.json([ + { + gatewayBaseUrl: + "http://localhost:4000/providers/26010000-0000-4000-8000-000000000010", + id: "26010000-0000-4000-8000-000000000010", + name: "OpenRouter", + protocolProfile: "openai-compatible", + status: "ACTIVE", + workspaceId: "26010000-0000-4000-8000-000000000003", + }, + ]); + } + if (url.pathname.endsWith("/chat/completions")) { + const headers = new Headers(init?.headers); + imageRequests.push({ + authorization: headers.get("authorization"), + body: JSON.parse(String(init?.body)), + requestId: headers.get("x-nexus-request-id"), + }); + imageAttempts += 1; + if (imageAttempts === 1) { + return Response.json( + { + error: { + code: "metering_unsupported", + message: + "raw upstream detail containing secret-token and secret-prompt", + }, + request_id: "sk-or-v1-secret-token", + }, + { + headers: { + "x-nexus-request-id": "secret-prompt", + "x-request-id": "sk-or-v1-secret-token", + }, + status: 409, + }, + ); + } + if (imageAttempts === 2) { + return Response.json( + { + error: { code: "secret-prompt" }, + padding: "x".repeat(70 * 1024), + request_id: "sk-or-v1-secret-token", + }, + { status: 500 }, + ); + } + return Response.json( + { + error: { + code: "secret-prompt", + requestId: "sk-or-v1-secret-token", + }, + request_id: "secret-prompt", + }, + { status: 422 }, + ); + } + throw new Error(`Unexpected request: ${url.pathname}`); + }, + now: () => new Date("2026-07-26T08:00:00.000Z"), + }); + + let rejected: unknown; + try { + await client.imageCompletion( + "openai/gpt-image-1", + "secret-prompt", + "operation-123", + new AbortController().signal, + ); + } catch (error) { + rejected = error; + } + expect(rejected).toBeInstanceOf(NexusImageHttpError); + expect(rejected).toMatchObject({ + code: "metering_unsupported", + requestId: "operation-123", + status: 409, + }); + expect(String(rejected)).not.toContain("secret-token"); + expect(String(rejected)).not.toContain("secret-prompt"); + expect(imageRequests[0]).toEqual({ + authorization: "Bearer fresh-data-token-with-sufficient-length", + body: { + messages: [{ content: "secret-prompt", role: "user" }], + modalities: ["image", "text"], + model: "openai/gpt-image-1", + stream: false, + }, + requestId: "operation-123", + }); + + let oversized: unknown; + try { + await client.imageCompletion( + "openai/gpt-image-1", + "another prompt", + "operation-oversized", + new AbortController().signal, + ); + } catch (error) { + oversized = error; + } + expect(oversized).toBeInstanceOf(NexusImageHttpError); + expect(oversized).toMatchObject({ + requestId: "operation-oversized", + status: 500, + }); + expect((oversized as NexusImageHttpError).code).toBeUndefined(); + + let untrustedDiagnostics: unknown; + try { + await client.imageCompletion( + "openai/gpt-image-1", + "third prompt", + "operation-json", + new AbortController().signal, + ); + } catch (error) { + untrustedDiagnostics = error; + } + expect(untrustedDiagnostics).toMatchObject({ + code: undefined, + requestId: "operation-json", + status: 422, + }); + expect(JSON.stringify(untrustedDiagnostics)).not.toContain("secret-prompt"); + expect(JSON.stringify(untrustedDiagnostics)).not.toContain( + "sk-or-v1-secret-token", + ); + }); });