feat(supplier): stateful-upstream contract for chat-session (OpenClaw)

Two opt-in envs preparing the openclaw supplier on inference-proxy:

OPENAI_SESSION_PASSTHROUGH=1 — chat-session turns speak the stateful-
upstream contract: the escrow ref is sent as the OpenAI `user` field
(OpenClaw keys one persistent agent session per `user`, so browser/
tool state survives across turns) and only the current turn's delta
goes upstream — the upstream session carries history, and re-sending
the full transcript would duplicate it into the upstream context every
turn (OpenClaw renders ALL request messages into the new agent turn).
The local transcript still accumulates fully for the receipt. Without
`user`, OpenClaw creates an ephemeral session per request (uuid key),
so default-off stays safe for stateless providers.

OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
advert's buyer-facing model. OpenClaw's chat-completions endpoint 400s
on anything but "openclaw" / "openclaw/<agentId>", while the advert
must carry a unique fleet-routable id (openclaw-web-agent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
david
2026-08-13 15:08:00 +02:00
parent 48ad639d12
commit 843ac7cbdd
8 changed files with 186 additions and 7 deletions

View File

@@ -36,6 +36,15 @@
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
* first-token latency is longer than local Ollama)
* OPENAI_SESSION_PASSTHROUGH
* — "1" switches chat-session turns to the stateful-
* upstream contract (OpenClaw): escrow ref as the
* OpenAI `user` field + delta-only messages per turn.
* Strict "1" only. Default off. Requires
* CAPABILITY_KIND="chat-session".
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
* advert's buyer-facing model (OpenClaw accepts only
* "openclaw" / "openclaw/<agentId>"). Default unset.
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE")
@@ -129,6 +138,28 @@ export interface SupplierConfig {
* the model context or the provider 400s (e.g. kimi context = 262144).
*/
openaiMaxTokens: number;
/**
* When true, chat-session turns speak the STATEFUL-upstream contract
* (e.g. an OpenClaw gateway): each turn sends the escrow ref as the OpenAI
* `user` field (OpenClaw keys one persistent agent session per `user`, so
* browser/tool state survives across turns) and sends ONLY the current
* turn's delta messages — the upstream session carries the history, and
* re-sending the full transcript would duplicate it into the upstream
* context every turn. The supplier still accumulates the full transcript
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
* off (stateless upstreams get the full transcript each turn). Requires
* capabilityKind="chat-session".
*/
openaiSessionPassthrough: boolean;
/**
* When non-empty, sent as the upstream `model` instead of the advert's
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
* chat-completions endpoint 400s on anything but "openclaw" or
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
* sent verbatim).
*/
openaiModelOverride: string;
/** Empty string when capabilityKind="chat". */
piperUrl: string;
advertRef: AdvertRef;
@@ -379,6 +410,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiMaxTokens = Number(maxTokStr);
}
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
// field + delta-only messages (the upstream session carries history).
// Strict "1" only, mirroring LIVE_CHAIN.
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
throw new Error(
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
);
}
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
// advert's (buyer-facing) model. Needed when the upstream validates the
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
let chatIdleTimeoutMs = 300_000;
@@ -493,6 +540,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiApiKey,
openaiReasoningDisabled,
openaiMaxTokens,
openaiSessionPassthrough,
openaiModelOverride,
piperUrl,
advertRef,
networkId,

View File

@@ -92,7 +92,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
if (deps.config.llmBackend === "openai") {
inference = await openai.callOpenAi({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
model: deps.config.openaiModelOverride || advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,

View File

@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
* context window (some providers 400 when it exceeds context).
*/
maxTokens?: number;
/**
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
* escrow ref pins one upstream session per marketplace chat session.
* Stateless providers ignore it.
*/
user?: string;
/**
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
@@ -93,11 +100,12 @@ function isAbortError(err: unknown): boolean {
}
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = { model, messages, stream: false };
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" };
@@ -215,7 +223,7 @@ export async function callOpenAiStream(
params: CallOpenAiParams,
onToken: (delta: string) => void,
): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = {
model,
@@ -225,6 +233,7 @@ export async function callOpenAiStream(
};
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
if (Array.isArray(tools) && tools.length > 0) {
payload.tools = tools;
if (toolChoice !== undefined) payload.tool_choice = toolChoice;

View File

@@ -1272,17 +1272,23 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
};
try {
// Stateful upstreams (OpenClaw) key a persistent agent session off the
// OpenAI `user` field and carry the history themselves — send only this
// turn's delta or the transcript duplicates into the upstream context
// every turn. Stateless upstreams get the full transcript as before.
const stateful = deps.config.openaiSessionPassthrough;
const result = await callOpenAiStream(
{
baseUrl: deps.config.openaiBaseUrl,
model: record.advert.model,
messages: record.transcript,
model: deps.config.openaiModelOverride || record.advert.model,
messages: stateful ? delta : record.transcript,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
maxTokens: deps.config.openaiMaxTokens,
disableReasoning: deps.config.openaiReasoningDisabled,
tools,
toolChoice,
user: stateful ? escrowRefStr : undefined,
},
(tok) => sse({ type: "token", value: tok }),
);

View File

@@ -37,7 +37,7 @@ function chatSessionConfig(): SupplierConfig {
} as SupplierConfig;
}
function makeAppWithSession() {
function makeAppWithSession(configOverrides?: Partial<SupplierConfig>) {
const chatSessions = new ChatSessionStore();
const record = chatSessions.create({
escrowRef: ESCROW_REF,
@@ -48,7 +48,7 @@ function makeAppWithSession() {
const app = createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: chatSessionConfig(),
config: { ...chatSessionConfig(), ...configOverrides } as SupplierConfig,
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
chatSessions,
@@ -91,6 +91,54 @@ describe("ChatSessionStore.truncateTranscript", () => {
});
});
describe("POST /v1/chat/message — stateful upstream mode (OPENAI_SESSION_PASSTHROUGH)", () => {
it("sends delta-only messages + user=escrowRef + overridden model; transcript still accumulates fully", async () => {
const { app, record } = makeAppWithSession({
openaiSessionPassthrough: true,
openaiModelOverride: "openclaw",
});
const okFetch = () =>
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
const fetch1 = okFetch();
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const fetch2 = okFetch();
vi.stubGlobal("fetch", fetch2);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "second" });
// Turn 2 upstream call: only the new delta, keyed to the escrow ref, with
// the fixed upstream model — NOT the accumulated transcript / advert model.
const body2 = JSON.parse(fetch2.mock.calls[0][1].body as string);
expect(body2.messages).toEqual([{ role: "user", content: "second" }]);
expect(body2.user).toBe(ESCROW_REF);
expect(body2.model).toBe("openclaw");
// The local transcript (receipt source of truth) still has every turn.
expect(record.transcript).toEqual([
{ role: "user", content: "first" },
{ role: "assistant", content: "Hello" },
{ role: "user", content: "second" },
{ role: "assistant", content: "Hello" },
]);
});
it("keeps full-transcript + advert-model behavior when passthrough is off", async () => {
const { app } = makeAppWithSession();
const fetch1 = vi
.fn()
.mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const body = JSON.parse(fetch1.mock.calls[0][1].body as string);
expect(body.messages).toEqual([{ role: "user", content: "first" }]);
expect("user" in body).toBe(false);
expect(body.model).toBe("kimi");
});
});
describe("POST /v1/chat/message — failed-turn rollback", () => {
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
const { app, record } = makeAppWithSession();

View File

@@ -294,3 +294,40 @@ describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
});
});
// ─── loadConfig — OPENAI_SESSION_PASSTHROUGH ─────────────────────────────────
describe("loadConfig() — OPENAI_SESSION_PASSTHROUGH", () => {
const chatSessionEnv = () => ({
...buildSampleEnv(),
CAPABILITY_KIND: "chat-session",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: "http://up",
});
it("defaults off", () => {
expect(loadConfig(chatSessionEnv()).openaiSessionPassthrough).toBe(false);
});
it('parses "1" for chat-session kind', () => {
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "1" });
expect(cfg.openaiSessionPassthrough).toBe(true);
});
it('treats values other than "1" as off (mirrors LIVE_CHAIN strictness)', () => {
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "true" });
expect(cfg.openaiSessionPassthrough).toBe(false);
});
it("rejects it for non-chat-session kinds", () => {
expect(() => loadConfig({ ...buildSampleEnv(), OPENAI_SESSION_PASSTHROUGH: "1" })).toThrow(
/chat-session/,
);
});
it("parses OPENAI_MODEL_OVERRIDE (default empty = advert model verbatim)", () => {
expect(loadConfig(chatSessionEnv()).openaiModelOverride).toBe("");
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_MODEL_OVERRIDE: "openclaw" });
expect(cfg.openaiModelOverride).toBe("openclaw");
});
});

View File

@@ -52,6 +52,22 @@ describe("callOpenAiStream — tools passthrough", () => {
expect("tool_choice" in body).toBe(false);
});
it("includes user in the payload when provided (session passthrough)", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream({ ...BASE, user: `${"f".repeat(64)}#0` }, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect(body.user).toBe(`${"f".repeat(64)}#0`);
});
it("omits user from the payload when not provided", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream(BASE, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect("user" in body).toBe(false);
});
it("accumulates fragmented delta.tool_calls across frames", async () => {
const frames = sse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },

View File

@@ -103,6 +103,20 @@ describe("callOpenAi() — request shape", () => {
expect(body.stream).toBe(false);
});
it("includes user in the payload when provided (session passthrough)", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS, user: `${"f".repeat(64)}#0` });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.user).toBe(`${"f".repeat(64)}#0`);
});
it("omits user from the payload when not provided", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect("user" in body).toBe(false);
});
it("omits authorization header when apiKey is not provided (ChatMock path)", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];