|
| 1 | +import { Type } from "typebox"; |
| 2 | +import { describe, expect, it } from "vitest"; |
| 3 | +import type { Context, Model } from "../types.js"; |
| 4 | +import { streamOpenAIResponses } from "./openai-responses.js"; |
| 5 | + |
| 6 | +// Live coverage for the Responses stream state machine: real streams interleave |
| 7 | +// reasoning/message/tool items, so unit fakes alone cannot prove slot tracking, |
| 8 | +// terminal-event handling, or the max_output_tokens floor against the real API. |
| 9 | +const LIVE = process.env.OPENCLAW_LIVE_TEST === "1"; |
| 10 | +const OPENAI_KEY = process.env.OPENAI_API_KEY ?? ""; |
| 11 | +const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip; |
| 12 | + |
| 13 | +const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_RESPONSES_MODEL || "gpt-5.6-luna"; |
| 14 | +const LIVE_TIMEOUT_MS = 120_000; |
| 15 | + |
| 16 | +function liveModel(overrides: Partial<Model<"openai-responses">> = {}) { |
| 17 | + return { |
| 18 | + id: LIVE_MODEL_ID, |
| 19 | + name: LIVE_MODEL_ID, |
| 20 | + api: "openai-responses", |
| 21 | + provider: "openai", |
| 22 | + baseUrl: "https://api.openai.com/v1", |
| 23 | + reasoning: true, |
| 24 | + input: ["text"], |
| 25 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 26 | + contextWindow: 200_000, |
| 27 | + maxTokens: 8192, |
| 28 | + ...overrides, |
| 29 | + } satisfies Model<"openai-responses">; |
| 30 | +} |
| 31 | + |
| 32 | +describeLive("OpenAI Responses live", () => { |
| 33 | + it( |
| 34 | + "streams a reply through a terminal event with usage accounting", |
| 35 | + async () => { |
| 36 | + const context: Context = { |
| 37 | + messages: [{ role: "user", content: "Reply with exactly: OK", timestamp: 0 }], |
| 38 | + }; |
| 39 | + const result = await streamOpenAIResponses(liveModel(), context, { |
| 40 | + apiKey: OPENAI_KEY, |
| 41 | + maxTokens: 256, |
| 42 | + }).result(); |
| 43 | + |
| 44 | + expect(result.errorMessage).toBeUndefined(); |
| 45 | + expect(result.stopReason).toBe("stop"); |
| 46 | + const text = result.content |
| 47 | + .filter((block) => block.type === "text") |
| 48 | + .map((block) => block.text) |
| 49 | + .join(""); |
| 50 | + expect(text).toContain("OK"); |
| 51 | + expect(result.usage.totalTokens).toBeGreaterThan(0); |
| 52 | + expect(result.usage.output).toBeGreaterThan(0); |
| 53 | + }, |
| 54 | + LIVE_TIMEOUT_MS, |
| 55 | + ); |
| 56 | + |
| 57 | + it( |
| 58 | + "clamps a sub-floor max token budget instead of failing the request", |
| 59 | + async () => { |
| 60 | + const context: Context = { |
| 61 | + messages: [{ role: "user", content: "Reply with exactly: OK", timestamp: 0 }], |
| 62 | + }; |
| 63 | + const result = await streamOpenAIResponses(liveModel(), context, { |
| 64 | + apiKey: OPENAI_KEY, |
| 65 | + maxTokens: 1, |
| 66 | + }).result(); |
| 67 | + |
| 68 | + // Pre-floor behavior was a hard 400 from the API; truncation is expected. |
| 69 | + expect(result.errorMessage).toBeUndefined(); |
| 70 | + expect(["stop", "length"]).toContain(result.stopReason); |
| 71 | + }, |
| 72 | + LIVE_TIMEOUT_MS, |
| 73 | + ); |
| 74 | + |
| 75 | + it( |
| 76 | + "keeps reasoning and tool-call items separable on a real interleaved stream", |
| 77 | + async () => { |
| 78 | + const context: Context = { |
| 79 | + messages: [ |
| 80 | + { |
| 81 | + role: "user", |
| 82 | + content: "Call the live_probe tool with value set to exactly LIVE_OK.", |
| 83 | + timestamp: 0, |
| 84 | + }, |
| 85 | + ], |
| 86 | + tools: [ |
| 87 | + { |
| 88 | + name: "live_probe", |
| 89 | + description: "Records a probe value.", |
| 90 | + parameters: Type.Object({ value: Type.String() }), |
| 91 | + }, |
| 92 | + ], |
| 93 | + }; |
| 94 | + const result = await streamOpenAIResponses(liveModel(), context, { |
| 95 | + apiKey: OPENAI_KEY, |
| 96 | + maxTokens: 1024, |
| 97 | + reasoningEffort: "low", |
| 98 | + }).result(); |
| 99 | + |
| 100 | + expect(result.errorMessage).toBeUndefined(); |
| 101 | + expect(result.stopReason).toBe("toolUse"); |
| 102 | + const toolCalls = result.content.filter((block) => block.type === "toolCall"); |
| 103 | + expect(toolCalls).toHaveLength(1); |
| 104 | + expect(toolCalls[0]?.name).toBe("live_probe"); |
| 105 | + expect((toolCalls[0]?.arguments as { value?: string }).value).toBe("LIVE_OK"); |
| 106 | + for (const block of result.content) { |
| 107 | + if (block.type === "thinking") { |
| 108 | + expect(block.thinkingSignature).toBeTruthy(); |
| 109 | + } |
| 110 | + } |
| 111 | + }, |
| 112 | + LIVE_TIMEOUT_MS, |
| 113 | + ); |
| 114 | +}); |
0 commit comments