diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a..261aece1d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b7d9c8614..47354d4219 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -38,6 +38,7 @@ import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, TRANSLATOR_MAX_SSE_EVENT_BYTES, + TranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; @@ -328,6 +329,9 @@ interface ReasoningDetailSegment { text: string; } +const MAX_REASONING_DETAIL_ID_BYTES = 1024; +const MAX_REASONING_DETAIL_SEGMENTS = 1024; + /** * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`). * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the @@ -341,11 +345,17 @@ function reasoningDetailSegmentsFrom(record: Record): Reasoning const item: unknown = raw[i]; if (!isRecord(item)) continue; if (typeof item.text !== "string" || item.text.length === 0) continue; - const key = typeof item.id === "string" && item.id.length > 0 - ? `id:${item.id}` - : typeof item.index === "number" - ? `i:${item.index}` - : `n:${i}`; + let key: string; + if (typeof item.id === "string" && item.id.length > 0) { + if (new TextEncoder().encode(item.id).byteLength > MAX_REASONING_DETAIL_ID_BYTES) { + throw new TranslatorBudgetExceededError("reasoning", MAX_REASONING_DETAIL_ID_BYTES); + } + key = `id:${item.id}`; + } else if (typeof item.index === "number") { + key = `i:${item.index}`; + } else { + key = `n:${i}`; + } segments.push({ key, text: item.text }); } return segments; @@ -1727,6 +1737,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // A piece that does not extend the previous snapshot is appended whole, which // keeps incremental senders parseable on the same path. const reasoningDetailSnapshots = new Map(); + let reasoningDetailSnapshotBytes = 0; // Gate on the routed model, not list length: a mixed openai-chat provider // can list MiniMax ids without putting every sibling on MiniMax semantics. const reasoningDetailsOptIn = modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? ""); @@ -1789,15 +1800,31 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const detailSegments = reasoningDetailsOptIn ? reasoningDetailSegmentsFrom(delta) : []; if (detailSegments.length > 0) { for (const segment of detailSegments) { - const prev = reasoningDetailSnapshots.get(segment.key) ?? ""; + const existing = reasoningDetailSnapshots.get(segment.key); + if (existing === undefined && reasoningDetailSnapshots.size >= MAX_REASONING_DETAIL_SEGMENTS) { + throw new TranslatorBudgetExceededError("reasoning", MAX_REASONING_DETAIL_SEGMENTS); + } + const prev = existing ?? ""; if (segment.text === prev) continue; - if (segment.text.startsWith(prev)) { - reasoningDetailSnapshots.set(segment.key, segment.text); - yield { type: "reasoning_raw_delta", text: segment.text.slice(prev.length) }; - } else { - reasoningDetailSnapshots.set(segment.key, prev + segment.text); - yield { type: "reasoning_raw_delta", text: segment.text }; + const next = segment.text.startsWith(prev) ? segment.text : prev + segment.text; + const previousBytes = existing === undefined + ? 0 + : budgetEncoder.encode(segment.key).byteLength + budgetEncoder.encode(prev).byteLength; + const nextBytes = budgetEncoder.encode(segment.key).byteLength + budgetEncoder.encode(next).byteLength; + const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" }); + try { + reasoningDetailSnapshots.set(segment.key, next); + reservation.commitRetained(); + budget.releaseRetained(previousBytes, { kind: "reasoning" }); + reasoningDetailSnapshotBytes += nextBytes - previousBytes; + } catch (error) { + reservation.release(); + throw error; } + yield { + type: "reasoning_raw_delta", + text: segment.text.startsWith(prev) ? segment.text.slice(prev.length) : segment.text, + }; } } else { const reasoningText = reasoningTextFrom(delta); @@ -2017,6 +2044,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd throw error; } finally { budget.releaseRetained(bufferBytes, { kind: "live_transient" }); + budget.releaseRetained(reasoningDetailSnapshotBytes, { kind: "reasoning" }); + reasoningDetailSnapshots.clear(); closeToolCalls(); reader.releaseLock(); } diff --git a/tests/minimax-reasoning-split.test.ts b/tests/minimax-reasoning-split.test.ts index ad2b44d57e..2dd1004032 100644 --- a/tests/minimax-reasoning-split.test.ts +++ b/tests/minimax-reasoning-split.test.ts @@ -202,6 +202,42 @@ describe("MiniMax split reasoning", () => { expect(events).toContainEqual({ type: "text_delta", text: "answer" }); }); + test("streaming reasoning detail snapshots are bounded by the translation budget", async () => { + const route = minimaxRoute("MiniMax-M3"); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (let index = 0; index < 20; index++) { + const chunk = { choices: [{ delta: { reasoning_details: [{ id: `segment-${index}`, text: "x" }] } }] }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + controller.close(); + }, + }); + const budget = createTranslatorBudget({ maxTurnBytes: 256 }); + const events = []; + + for await (const event of adapterFor(route.provider, route.modelId).parseStream(new Response(stream), budget)) { + events.push(event); + } + + expect(events.at(-1)).toMatchObject({ type: "error", code: "translation_buffer_limit" }); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("rejects oversized reasoning detail ids without retaining them", async () => { + const route = minimaxRoute("MiniMax-M3"); + const chunk = { choices: [{ delta: { reasoning_details: [{ id: "x".repeat(1025), text: "thinking" }] } }] }; + const stream = new Response(`data: ${JSON.stringify(chunk)}\n\n`); + const budget = createTranslatorBudget(); + const events = []; + + for await (const event of adapterFor(route.provider, route.modelId).parseStream(stream, budget)) events.push(event); + + expect(events).toEqual([expect.objectContaining({ type: "error", code: "translation_buffer_limit" })]); + expect(budget.snapshot().currentBytes).toBe(0); + }); + test("providers without reasoning_details opt-in keep ignoring the array", async () => { const provider: OcxProviderConfig = { adapter: "openai-chat",