


























@@ -113,6 +113,41 @@ function latestTrustedEnvProxyParams(): Record<string, unknown> {
113113return params;
114114}
115115116+function responseStreamText(text: string): ReadableStream<Uint8Array> {
117+return responseStreamChunks([text]);
118+}
119+120+function responseStreamChunks(chunks: string[]): ReadableStream<Uint8Array> {
121+const encoder = new TextEncoder();
122+return new ReadableStream({
123+start(controller) {
124+for (const chunk of chunks) {
125+controller.enqueue(encoder.encode(chunk));
126+}
127+controller.close();
128+},
129+});
130+}
131+132+function openResponseStreamText(text: string): {
133+close: () => void;
134+stream: ReadableStream<Uint8Array>;
135+} {
136+const encoder = new TextEncoder();
137+let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
138+return {
139+close() {
140+streamController?.close();
141+},
142+stream: new ReadableStream({
143+start(controller) {
144+streamController = controller;
145+controller.enqueue(encoder.encode(text));
146+},
147+}),
148+};
149+}
150+116151describe("buildGuardedModelFetch", () => {
117152beforeEach(() => {
118153managedStreamCleanupRegistrations.length = 0;
@@ -203,6 +238,164 @@ describe("buildGuardedModelFetch", () => {
203238expect(release).toHaveBeenCalled();
204239});
205240241+it("allows missing content-type when streamed OpenAI-compatible responses contain SSE", async () => {
242+fetchWithSsrFGuardMock.mockResolvedValue({
243+response: new Response(responseStreamText('data: {"ok": true}\n\ndata: [DONE]\n\n')),
244+finalUrl: "https://chatgpt.com/backend-api/codex/responses",
245+release: vi.fn(async () => undefined),
246+});
247+const model = {
248+id: "gpt-5.5",
249+provider: "openai",
250+api: "openclaw-openai-responses-transport",
251+baseUrl: "https://chatgpt.com/backend-api/codex",
252+} as unknown as Model<"openai-responses">;
253+254+const response = await buildGuardedModelFetch(model)(
255+"https://chatgpt.com/backend-api/codex/responses",
256+{
257+method: "POST",
258+headers: { "content-type": "application/json" },
259+body: JSON.stringify({ model: "gpt-5.5", stream: true }),
260+},
261+);
262+const items = [];
263+for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
264+items.push(item);
265+}
266+267+expect(items).toEqual([{ ok: true }]);
268+});
269+270+it("returns promptly for missing content-type SSE streams that remain open", async () => {
271+const source = openResponseStreamText('data: {"ok": true}\n\n');
272+fetchWithSsrFGuardMock.mockResolvedValue({
273+response: new Response(source.stream),
274+finalUrl: "https://chatgpt.com/backend-api/codex/responses",
275+release: vi.fn(async () => undefined),
276+});
277+const model = {
278+id: "gpt-5.5",
279+provider: "openai",
280+api: "openclaw-openai-responses-transport",
281+baseUrl: "https://chatgpt.com/backend-api/codex",
282+} as unknown as Model<"openai-responses">;
283+284+const responsePromise = buildGuardedModelFetch(model)(
285+"https://chatgpt.com/backend-api/codex/responses",
286+{
287+method: "POST",
288+headers: { "content-type": "application/json" },
289+body: JSON.stringify({ model: "gpt-5.5", stream: true }),
290+},
291+);
292+const timeout = Symbol("timeout");
293+const result = await Promise.race<Response | typeof timeout>([
294+responsePromise,
295+new Promise<typeof timeout>((resolve) => {
296+setTimeout(() => resolve(timeout), 100);
297+}),
298+]);
299+source.close();
300+301+expect(result).not.toBe(timeout);
302+const response = result as Response;
303+const items = [];
304+for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
305+items.push(item);
306+}
307+308+expect(items).toEqual([{ ok: true }]);
309+});
310+311+it("allows missing content-type when the SSE prefix is split across chunks", async () => {
312+fetchWithSsrFGuardMock.mockResolvedValue({
313+response: new Response(responseStreamChunks(["d", "ata", ': {"ok": true}\n\n'])),
314+finalUrl: "https://chatgpt.com/backend-api/codex/responses",
315+release: vi.fn(async () => undefined),
316+});
317+const model = {
318+id: "gpt-5.5",
319+provider: "openai",
320+api: "openclaw-openai-responses-transport",
321+baseUrl: "https://chatgpt.com/backend-api/codex",
322+} as unknown as Model<"openai-responses">;
323+324+const response = await buildGuardedModelFetch(model)(
325+"https://chatgpt.com/backend-api/codex/responses",
326+{
327+method: "POST",
328+headers: { "content-type": "application/json" },
329+body: JSON.stringify({ model: "gpt-5.5", stream: true }),
330+},
331+);
332+const items = [];
333+for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
334+items.push(item);
335+}
336+337+expect(items).toEqual([{ ok: true }]);
338+});
339+340+it("synthesizes SSE for missing content-type JSON returned to streaming SDK requests", async () => {
341+fetchWithSsrFGuardMock.mockResolvedValue({
342+response: new Response(responseStreamText('{"ok": true}')),
343+finalUrl: "https://chatgpt.com/backend-api/codex/responses",
344+release: vi.fn(async () => undefined),
345+});
346+const model = {
347+id: "gpt-5.5",
348+provider: "openai",
349+api: "openclaw-openai-responses-transport",
350+baseUrl: "https://chatgpt.com/backend-api/codex",
351+} as unknown as Model<"openai-responses">;
352+353+const response = await buildGuardedModelFetch(model)(
354+"https://chatgpt.com/backend-api/codex/responses",
355+{
356+method: "POST",
357+headers: { "content-type": "application/json" },
358+body: JSON.stringify({ model: "gpt-5.5", stream: true }),
359+},
360+);
361+const items = [];
362+for await (const item of Stream.fromSSEResponse(response, new AbortController())) {
363+items.push(item);
364+}
365+366+expect(response.headers.get("content-type")).toContain("text/event-stream");
367+expect(items).toEqual([{ ok: true }]);
368+});
369+370+it("rejects missing content-type streamed OpenAI-compatible responses with HTML bodies", async () => {
371+const release = vi.fn(async () => undefined);
372+const model = {
373+id: "private-model",
374+provider: "custom-openai",
375+api: "openai-completions",
376+baseUrl: "https://proxy.example.com",
377+} as unknown as Model<"openai-completions">;
378+fetchWithSsrFGuardMock.mockResolvedValue({
379+response: new Response(responseStreamText("<html>not the API</html>")),
380+finalUrl: "https://proxy.example.com/chat/completions",
381+ release,
382+});
383+384+await expect(
385+buildGuardedModelFetch(model)("https://proxy.example.com/chat/completions", {
386+method: "POST",
387+headers: { "content-type": "application/json" },
388+body: JSON.stringify({ model: "private-model", stream: true }),
389+}),
390+).rejects.toMatchObject({
391+name: "ProviderHttpError",
392+status: 200,
393+code: "invalid_provider_content_type",
394+errorType: "invalid_response",
395+});
396+expect(release).toHaveBeenCalled();
397+});
398+206399it("ensures configured local services before the model request", async () => {
207400const release = vi.fn();
208401ensureModelProviderLocalServiceMock.mockResolvedValue({ release });
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。