OpenBMB / OpenBMB/PilotDeck

API content objects are silently stringified as [object Object]

Open
#501 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
4k
Forks
453
Avg merge
12h 30m
Merged PRs (30d)
46

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the provided repro_api_content_object_stringification.mts fixture after installing dependencies, then inspect the cited ranges in ApiServerChannel.ts, gateway/protocol/types.ts, and InProcessGateway.ts. Trace how object content is normalized in buffered and streaming requests; done means structured objects are rejected or schema-decoded without [object Object], with regression coverage for both modes.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.