Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d35592b
merge dev into main for the v2.32.1 release
lidge-jun Aug 25, 2026
71c57ea
release: v2.32.1
lidge-jun Aug 25, 2026
d560ac6
merge dev into main for the v2.33.0 release
lidge-jun Aug 25, 2026
08ada6f
Merge pull request #2553 from lidge-jun/codex/promote-main-2330
lidge-jun Aug 25, 2026
ec51e42
release: v2.33.0
lidge-jun Aug 25, 2026
e25b653
merge dev into main for the v2.34.0 release
lidge-jun Aug 27, 2026
80fff9a
Merge pull request #2760 from lidge-jun/codex/promote-main-2340
lidge-jun Aug 27, 2026
fc4de77
Merge pull request #2826 from lidge-jun/codex/promote-main-2350
lidge-jun Aug 28, 2026
c7d8407
Merge pull request #3002 from lidge-jun/codex/promote-main-2360
lidge-jun Aug 30, 2026
54e2274
Merge pull request #3037 from lidge-jun/codex/promote-main-2370
lidge-jun Aug 31, 2026
2c4dca1
merge dev into the promotion branch for v2.38.0
lidge-jun Aug 31, 2026
a34e8b7
merge dev into the promotion branch for v2.38.0 (picks up the ReDoS fix)
lidge-jun Aug 31, 2026
ebb4d55
Merge pull request #3073 from lidge-jun/codex/promote-main-2380
lidge-jun Aug 31, 2026
682112e
Merge remote-tracking branch 'origin/dev' into codex/promote-main-2390
lidge-jun Sep 1, 2026
af6113a
merge dev into main for the v2.39.0 release
lidge-jun Sep 1, 2026
847f4f1
merge dev into main for the v2.40.0 release
Sep 2, 2026
ac78647
Merge pull request #3261 from lidge-jun/codex/promote-main-2400
lidge-jun Sep 2, 2026
aaa9eaf
fix(release): pass the bump job's permissions through the reusable-wo…
lidge-jun Sep 2, 2026
35ff3a4
Merge pull request #3263 from lidge-jun/codex/promote-main-2400-relfix
lidge-jun Sep 2, 2026
c3a289d
fix(openai-chat): bound reasoning detail snapshots
luvs01 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
53 changes: 41 additions & 12 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
isTranslatorBudgetExceededError,
retainTranslatedEventBatch,
TRANSLATOR_MAX_SSE_EVENT_BYTES,
TranslatorBudgetExceededError,
type TranslatorBudget,
} from "../lib/translator-budget";

Expand Down Expand Up @@ -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
Expand All @@ -341,11 +345,17 @@ function reasoningDetailSegmentsFrom(record: Record<string, unknown>): 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) {
Comment on lines +348 to +350

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict the ID cap to streamed snapshots

For a non-streaming MiniMax completion that omits reasoning_content and supplies only reasoning_details, parseResponse also calls this shared helper at line 2131, even though that path discards every key and retains only the extracted text. Consequently, an otherwise valid response containing an ID over 1,024 bytes now throws TranslatorBudgetExceededError and loses the entire completion, despite the complete response already being charged to the translator budget. Apply the ID validation only in the streaming snapshot path, or let the helper skip key validation when keys are not needed.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

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;
Expand Down Expand Up @@ -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<string, string>();
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 ?? "");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down
36 changes: 36 additions & 0 deletions tests/minimax-reasoning-split.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>({
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",
Expand Down
Loading