Skip to content

API content objects are silently stringified as [object Object] #501

Description

@MicroMilo

Summary

Arbitrary objects and objects shaped like {type:text,text:...} return HTTP 200 in buffered and streaming modes. Each sends one Gateway call with message=[object Object], with no attachment or recoverable object structure.

Expected behavior

An object that is not a documented content representation should be rejected or decoded by schema. It must not be converted to a lossy JavaScript string.

Actual behavior

Arbitrary objects and objects shaped like {type:text,text:...} return HTTP 200 in buffered and streaming modes. Each sends one Gateway call with message=[object Object], with no attachment or recoverable object structure.

Impact

The API reports success while discarding structured input and sending literal [object Object] to the downstream model path.

Reproduction

POST /v1/chat/completions with a message whose content is an arbitrary JSON object, and repeat with an object shaped like {type:"text",text:"..."}; test buffered and streaming responses. The API should reject or decode the object according to its schema. The observed result is HTTP 200 while the downstream Gateway receives the literal string [object Object].

Minimal reproduction script

From the repository root, save this as repro_api_content_object_stringification.mts and run:

pnpm install --frozen-lockfile
pnpm exec tsx repro_api_content_object_stringification.mts
import { Readable } from "node:stream";
import { ApiServerChannel } from "./src/adapters/channel/api-server/ApiServerChannel.js";
import { ApiServerSessionMapper } from "./src/adapters/channel/api-server/ApiServerSessionMapper.js";
import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "./src/gateway/index.js";

type CaptureResponse = {
  statusCode: number;
  headers: Map<string, string>;
  ended: boolean;
  body: string;
  setHeader(name: string, value: string): void;
  flushHeaders(): void;
  write(chunk: string | Buffer): boolean;
  end(chunk?: string | Buffer): void;
};

function makeResponse(): CaptureResponse {
  const chunks: string[] = [];
  return {
    statusCode: 0,
    headers: new Map<string, string>(),
    ended: false,
    get body() { return chunks.join(""); },
    setHeader(name, value) { this.headers.set(name.toLowerCase(), String(value)); },
    flushHeaders() {},
    write(chunk) { chunks.push(String(chunk)); return true; },
    end(chunk) {
      if (chunk != null) chunks.push(String(chunk));
      this.ended = true;
    },
  };
}

function makeRequest(content: unknown, stream: boolean): any {
  const body = JSON.stringify({
    model: "object-stringification-model",
    messages: [{ role: "user", content }],
    stream,
  });
  const req = Readable.from([Buffer.from(body)]) as any;
  req.method = "POST";
  req.url = "/v1/chat/completions";
  req.headers = {
    host: "fixture.invalid",
    "content-type": "application/json",
    "x-hermes-session-id": "object-stringification-session",
  };
  return req;
}

function makeGateway(calls: Array<Record<string, unknown>>): Gateway {
  return {
    async *submitTurn(input: GatewaySubmitTurnInput): AsyncGenerator<GatewayEvent> {
      calls.push({
        keys: Object.keys(input).sort(),
        sessionKey: input.sessionKey,
        channelKey: input.channelKey,
        message: input.message,
        attachments: input.attachments ?? null,
      });
      yield { type: "assistant_text_delta", text: "fixture-reply" };
      yield {
        type: "turn_completed",
        usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
        finishReason: "completed",
      };
    },
  } as unknown as Gateway;
}

function summarizeBody(response: CaptureResponse): Record<string, unknown> {
  let parsed: any = null;
  try { parsed = JSON.parse(response.body); } catch { /* streaming body */ }
  return {
    responseObject: parsed?.object ?? null,
    assistantContent: parsed?.choices?.[0]?.message?.content ?? null,
    hasSseData: response.body.includes("data: "),
    hasDone: response.body.includes("data: [DONE]"),
  };
}

async function runCase(name: string, content: unknown, stream: boolean): Promise<Record<string, unknown>> {
  const calls: Array<Record<string, unknown>> = [];
  const mapper = new ApiServerSessionMapper({ activeByChatId: {} }, () => "object-stringification-uuid");
  const channel = new ApiServerChannel({ mapper, modelName: "object-stringification-model" });
  (channel as any).gateway = makeGateway(calls);
  const response = makeResponse();
  await (channel as any).handleRequest(makeRequest(content, stream), response);
  return {
    name,
    stream,
    status: response.statusCode,
    contentType: response.headers.get("content-type") ?? null,
    response: summarizeBody(response),
    gatewayCalls: calls,
    mapper: mapper.snapshot(),
  };
}

const cases = [
  { name: "arbitrary-object", content: { kind: "fixture-object", value: 7 } },
  { name: "object-text-shape", content: { type: "text", text: "fixture-object-text" } },
];

const results: Array<Record<string, unknown>> = [];
for (const item of cases) {
  results.push(await runCase(item.name, item.content, false));
  results.push(await runCase(item.name, item.content, true));
}

console.log(JSON.stringify({
  fixture: "api-server-content-object-stringification-witness",
  transport: "in-memory IncomingMessage/ServerResponse equivalent",
  endpoint: "POST /v1/chat/completions",
  gatewayOracle: "capture exact normalized message and input keys",
  results,
}, null, 2));

Relevant source locations

  • src/adapters/channel/api-server/ApiServerChannel.ts:225-235
  • src/adapters/channel/api-server/ApiServerChannel.ts:317-321
  • src/adapters/channel/api-server/ApiServerChannel.ts:374-378
  • src/adapters/channel/api-server/ApiServerChannel.ts:461-475
  • src/gateway/protocol/types.ts:86-94
  • src/gateway/client/InProcessGateway.ts:430-438

Suggested direction

Make the external-input path establish one durable, identity-bound state/receipt before returning success; propagate explicit terminal outcomes to every channel and client; and add a regression test for the reproduced boundary.

This report is about functional behavior, not security. The reproduction uses deterministic in-memory or isolated fixtures and contains no credentials or private data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions