diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml
index b5e0a0a55de..7d2e8d8fdb2 100644
--- a/.github/workflows/release-pipeline.yml
+++ b/.github/workflows/release-pipeline.yml
@@ -315,6 +315,7 @@ jobs:
path: |
tmp/newman-report*.json
tmp/newman-cli*.log
+ tmp/provider-harness-report.html
tmp/harness-failures.md
tmp/harness-token-parity.md
tmp/stream-cancel-report.json
@@ -341,6 +342,9 @@ jobs:
chmod +x ./.github/workflows/scripts/configure-r2.sh ./.github/workflows/scripts/upload-test-reports-to-r2.sh
./.github/workflows/scripts/configure-r2.sh || { echo "::warning::R2 not configured; skipping report publish"; exit 0; }
mkdir -p tmp/provider-harness-report
+ # index.html so the R2 key serves as a browsable page, matching the
+ # cli-harness reports the changelog links alongside it.
+ cp tmp/provider-harness-report.html tmp/provider-harness-report/index.html 2>/dev/null || true
cp tmp/harness-failures.md tmp/harness-token-parity.md tmp/provider-harness-report/ 2>/dev/null || true
./.github/workflows/scripts/upload-test-reports-to-r2.sh \
"${{ needs.detect-changes.outputs.transport-version }}" \
@@ -486,14 +490,14 @@ jobs:
include: ${{ fromJSON(needs.resolve-cli-versions.outputs.matrix) }}
name: test-cli-harness (${{ matrix.label }})
runs-on: ubuntu-latest
- # The four run_cases invocations in test-cli-harness.sh each pass
- # TIMEOUT=25m, so the Go suites alone can take 100m on top of the CLI
+ # The five run_cases invocations in test-cli-harness.sh each pass
+ # TIMEOUT=25m, so the Go suites alone can take 125m on top of the CLI
# installs, the UI build and the gateway build. A job timeout cancels the
# run, and the report upload is `if: !cancelled()`, so a cancel loses the
# harness artifacts too. Raised twice: 90 -> 120 when the opencode suite was
# added and the core scenarios went multi-turn, 120 -> 150 for the
- # opencode-responses suite.
- timeout-minutes: 150
+ # opencode-responses suite, 150 -> 180 for opencode-anthropic.
+ timeout-minutes: 180
permissions:
contents: read
steps:
@@ -615,6 +619,7 @@ jobs:
tmp/cli-harness-codex.log
tmp/cli-harness-opencode.log
tmp/cli-harness-opencode-responses.log
+ tmp/cli-harness-opencode-anthropic.log
# Per-attempt snapshots, written when a suite needed retries. The
# reports above are overwritten in place by each rerun, so this is
# the only record of what the first attempt failed on.
diff --git a/.github/workflows/scripts/detect-all-changes.sh b/.github/workflows/scripts/detect-all-changes.sh
index d71306bf51b..0227d942ff7 100755
--- a/.github/workflows/scripts/detect-all-changes.sh
+++ b/.github/workflows/scripts/detect-all-changes.sh
@@ -436,6 +436,25 @@ echo " Bifrost HTTP: $BIFROST_HTTP_NEEDS_RELEASE (v$TRANSPORT_VERSION)"
echo " Docker: $DOCKER_NEEDS_RELEASE (v$TRANSPORT_VERSION)"
# Set outputs (only when running in GitHub Actions)
+# Version strings are read straight out of the repo's version files and are then
+# interpolated by callers into shell commands (the R2 upload steps, the changelog
+# push, the docker manifest). A value carrying a quote, a space or a newline would
+# break out of the generated command, and GITHUB_OUTPUT is line-oriented so a
+# newline would additionally forge an extra output line. Nothing downstream can
+# defend against that once it is written, so it is rejected here, at the single
+# point where these values enter the workflow.
+for _pair in "core:$CORE_VERSION" "framework:$FRAMEWORK_VERSION" "transport:$TRANSPORT_VERSION"; do
+ _name="${_pair%%:*}"
+ _value="${_pair#*:}"
+ case "$_value" in
+ "") continue ;;
+ *[!0-9A-Za-z.+_-]*)
+ echo "โ refusing to emit $_name version with unexpected characters: $_value" >&2
+ exit 1
+ ;;
+ esac
+done
+
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "core-needs-release=$CORE_NEEDS_RELEASE"
diff --git a/.github/workflows/scripts/push-mintlify-changelog.sh b/.github/workflows/scripts/push-mintlify-changelog.sh
index 3b103c6d717..6575e1873ac 100755
--- a/.github/workflows/scripts/push-mintlify-changelog.sh
+++ b/.github/workflows/scripts/push-mintlify-changelog.sh
@@ -136,7 +136,7 @@ $CLI_HARNESS_INTRO
| Suite | What it covers | Report |
| --- | --- | --- |
-| Provider harness | Every provider ร modality through a live gateway | [failure breakdown]($REPORTS_BASE/provider-harness/harness-failures.md) |
+| Provider harness | Every provider ร modality through a live gateway | [requests]($REPORTS_BASE/provider-harness/index.html) ยท [failure breakdown]($REPORTS_BASE/provider-harness/harness-failures.md) |
$CLI_HARNESS_ROWS
The CLI harness reports include the full conversation for every scenario - each
@@ -201,18 +201,18 @@ if ! grep -q "\"$route\"" docs/docs.json; then
node -e "
const fs = require('fs');
const docs = JSON.parse(fs.readFileSync('docs/docs.json', 'utf8'));
-
+
// Semantic version comparison function
// Extracts version from route/filename and compares in descending order (newest first)
function compareVersionsDesc(a, b) {
// Extract route string from string or object
const routeA = typeof a === 'string' ? a : '';
const routeB = typeof b === 'string' ? b : '';
-
+
// Extract version from route (e.g., 'changelogs/v1.3.34' -> 'v1.3.34')
const versionA = routeA.split('/').pop() || '';
const versionB = routeB.split('/').pop() || '';
-
+
// Remove 'v' prefix and split into parts
const partsA = versionA.replace(/^v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
@@ -222,7 +222,7 @@ if ! grep -q "\"$route\"" docs/docs.json; then
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});
-
+
// Compare each part (major, minor, patch, pre-release, etc.)
const maxLength = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLength; i++) {
@@ -233,10 +233,10 @@ if ! grep -q "\"$route\"" docs/docs.json; then
if (partsB[i] === undefined && partsA[i] !== undefined) {
return 1; // B (release) comes first in descending order
}
-
+
const partA = partsA[i];
const partB = partsB[i];
-
+
// If both are numbers, compare numerically
if (typeof partA === 'number' && typeof partB === 'number') {
if (partA !== partB) {
@@ -248,7 +248,7 @@ if ! grep -q "\"$route\"" docs/docs.json; then
const strB = String(partB);
const matchA = strA.match(/^([a-zA-Z]+)(\\d+)$/);
const matchB = strB.match(/^([a-zA-Z]+)(\\d+)$/);
-
+
if (matchA && matchB && matchA[1] === matchB[1]) {
// Same prefix, compare numbers numerically
const numA = parseInt(matchA[2], 10);
@@ -261,45 +261,45 @@ if ! grep -q "\"$route\"" docs/docs.json; then
}
}
}
-
+
return 0; // Equal
}
-
+
// Sort a pages array by semver (descending)
function sortPagesBySemver(pages) {
return pages.slice().sort(compareVersionsDesc);
}
-
+
// Get current month/year
const releaseDate = new Date('$CURRENT_DATE');
const currentDate = new Date();
const releaseMonthYear = releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
const currentMonthYear = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
-
+
// Find the Changelogs tab
const changelogsTab = docs.navigation.tabs.find(tab => tab.tab === 'Changelogs');
if (!changelogsTab) {
console.error('Changelogs tab not found');
process.exit(1);
}
-
+
// Find the Open Source menu item
const openSourceItem = changelogsTab.menu?.find(item => item.item === 'Open Source');
if (!openSourceItem) {
console.error('Open Source menu item not found in Changelogs tab');
process.exit(1);
}
-
+
// Get all top-level entries and existing groups
const topLevelEntries = openSourceItem.pages.filter(p => typeof p === 'string');
const existingGroups = openSourceItem.pages.filter(p => typeof p === 'object');
-
+
// Check if we need to group existing top-level entries
if (topLevelEntries.length > 0) {
// Get the month of the first top-level entry (they should all be from same month)
const firstEntryPath = topLevelEntries[0].replace('changelogs/', '') + '.mdx';
const firstEntryFile = 'docs/changelogs/' + firstEntryPath;
-
+
let topLevelMonth = null;
try {
const content = fs.readFileSync(firstEntryFile, 'utf8');
@@ -311,22 +311,22 @@ if ! grep -q "\"$route\"" docs/docs.json; then
} catch (e) {
console.log(\`Warning: Could not read entry file \${firstEntryFile}: \${e.message}\`);
}
-
+
// Only group if the month has changed
if (topLevelMonth && topLevelMonth !== releaseMonthYear) {
console.log(\`๐ฆ Month changed from \${topLevelMonth} to \${releaseMonthYear}\`);
console.log(\`๐ฆ Grouping \${topLevelEntries.length} top-level entries into \${topLevelMonth} group...\`);
-
+
// Create a group for all existing top-level entries
const previousMonthGroup = {
group: topLevelMonth,
pages: sortPagesBySemver(topLevelEntries)
};
-
+
// Add this group at the top of existing groups
existingGroups.unshift(previousMonthGroup);
console.log(\`โ Created \${topLevelMonth} group with \${topLevelEntries.length} entries (sorted)\`);
-
+
// Clear top-level entries (they're now in the group)
openSourceItem.pages = existingGroups;
} else {
@@ -335,30 +335,30 @@ if ! grep -q "\"$route\"" docs/docs.json; then
openSourceItem.pages = [...topLevelEntries, ...existingGroups];
}
}
-
+
const newRoute = '$route';
-
+
// Add the new changelog at the top level
openSourceItem.pages.unshift(newRoute);
console.log(\`โ Added \${newRoute} to top level\`);
-
+
// Sort the top-level pages array by semver
const topLevelPages = openSourceItem.pages.filter(p => typeof p === 'string');
const groupPages = openSourceItem.pages.filter(p => typeof p === 'object');
-
+
if (topLevelPages.length > 0) {
const sortedTopLevel = sortPagesBySemver(topLevelPages);
openSourceItem.pages = [...sortedTopLevel, ...groupPages];
console.log(\`โ Sorted \${topLevelPages.length} top-level pages by semver\`);
}
-
+
// Sort each group's pages by semver
for (const group of groupPages) {
if (group.pages && Array.isArray(group.pages)) {
group.pages = sortPagesBySemver(group.pages);
}
}
-
+
fs.writeFileSync('docs/docs.json', JSON.stringify(docs, null, 2) + '\n');
console.log('โ Updated docs.json');
"
diff --git a/.github/workflows/scripts/test-cli-harness.sh b/.github/workflows/scripts/test-cli-harness.sh
index d50039653e5..33e04bb9e8e 100755
--- a/.github/workflows/scripts/test-cli-harness.sh
+++ b/.github/workflows/scripts/test-cli-harness.sh
@@ -103,6 +103,22 @@ OPENCODE_CASES="${OPENCODE_CASES:-TestCLIs/opencode/(openai|azure)/gpt-5\.5/(sim
# suite that costs Bedrock quota.
OPENCODE_RESPONSES_CASES="${OPENCODE_RESPONSES_CASES:-TestCLIs/opencode-responses/bedrock/global\.anthropic\.claude-sonnet-5/(simple-chat|reasoning-replay)}"
+# opencode-anthropic covers the THIRD wire format: /anthropic/v1/messages.
+#
+# It is the only client here that replays reasoning, and that is a protocol
+# constraint rather than a client preference -- the Anthropic API requires an
+# assistant turn's thinking blocks to be sent back verbatim when that turn
+# contains tool_use. The Responses SDK is free to rebuild history as prose and
+# does exactly that, which is why no amount of turns or tools makes the
+# opencode-responses suite exercise reasoning replay.
+#
+# reasoning-tool-replay is therefore the case that matters: it is the only cell
+# in the whole harness that reproduces the reported Bedrock 400
+# ("messages.2 ... reasoningContent.reasoningText.text ... Member must not be
+# null"). simple-chat is its control, and running both providers gives the
+# Anthropic wire coverage on the native path as well as the Bedrock one.
+OPENCODE_ANTHROPIC_CASES="${OPENCODE_ANTHROPIC_CASES:-TestCLIs/opencode-anthropic/(anthropic|bedrock)/(claude-sonnet-5|global\.anthropic\.claude-sonnet-5)/(simple-chat|reasoning-tool-replay)}"
+
# Retries per suite, re-running ONLY the cells that failed. Mirrors test-core's
# RERUN_FAILED policy (see test-provider-harness.sh): this suite is a required
# release gate, so a flaky cell must not sink a release on its own, while a real
@@ -350,12 +366,14 @@ CLAUDE_RC=0
CODEX_RC=0
OPENCODE_RC=0
OPENCODE_RESPONSES_RC=0
-# All four run even if an earlier one fails, so one broken CLI does not mask
+OPENCODE_ANTHROPIC_RC=0
+# All five run even if an earlier one fails, so one broken CLI does not mask
# the others.
run_cases "claude" "$CLAUDE_CASES" || CLAUDE_RC=$?
run_cases "codex" "$CODEX_CASES" || CODEX_RC=$?
run_cases "opencode" "$OPENCODE_CASES" || OPENCODE_RC=$?
run_cases "opencode-responses" "$OPENCODE_RESPONSES_CASES" || OPENCODE_RESPONSES_RC=$?
+run_cases "opencode-anthropic" "$OPENCODE_ANTHROPIC_CASES" || OPENCODE_ANTHROPIC_RC=$?
# Renders tests/e2e/clis/reports/index.html from the reports/*.json just written.
# Free and instant - no test re-execution.
@@ -379,6 +397,7 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "| codex | \`$CODEX_VERSION\` | \`$CODEX_CASES\` | $(attempts_for codex) | $([ "$CODEX_RC" -eq 0 ] && echo "โ pass" || echo "โ fail") |"
echo "| opencode | \`$OPENCODE_VERSION\` | \`$OPENCODE_CASES\` | $(attempts_for opencode) | $([ "$OPENCODE_RC" -eq 0 ] && echo "โ pass" || echo "โ fail") |"
echo "| opencode-responses | \`$OPENCODE_VERSION\` | \`$OPENCODE_RESPONSES_CASES\` | $(attempts_for opencode-responses) | $([ "$OPENCODE_RESPONSES_RC" -eq 0 ] && echo "โ pass" || echo "โ fail") |"
+ echo "| opencode-anthropic | \`$OPENCODE_VERSION\` | \`$OPENCODE_ANTHROPIC_CASES\` | $(attempts_for opencode-anthropic) | $([ "$OPENCODE_ANTHROPIC_RC" -eq 0 ] && echo "โ pass" || echo "โ fail") |"
echo ""
echo "Attempts > 1 means failed cells were re-run; only cells with status \`fail\` are retried."
echo "Full per-cell results are in the \`cli-harness-reports${CLI_VERSION_LABEL:+-$CLI_VERSION_LABEL}\` artifact (\`index.html\`),"
@@ -391,8 +410,8 @@ if [ ! -d "$REPORTS_DIR" ]; then
echo "โ ๏ธ No reports directory at $REPORTS_DIR - the harness may not have run any cells"
fi
-if [ "$CLAUDE_RC" -ne 0 ] || [ "$CODEX_RC" -ne 0 ] || [ "$OPENCODE_RC" -ne 0 ] || [ "$OPENCODE_RESPONSES_RC" -ne 0 ]; then
- echo "โ CLI harness failed (claude=$CLAUDE_RC, codex=$CODEX_RC, opencode=$OPENCODE_RC, opencode-responses=$OPENCODE_RESPONSES_RC)"
+if [ "$CLAUDE_RC" -ne 0 ] || [ "$CODEX_RC" -ne 0 ] || [ "$OPENCODE_RC" -ne 0 ] || [ "$OPENCODE_RESPONSES_RC" -ne 0 ] || [ "$OPENCODE_ANTHROPIC_RC" -ne 0 ]; then
+ echo "โ CLI harness failed (claude=$CLAUDE_RC, codex=$CODEX_RC, opencode=$OPENCODE_RC, opencode-responses=$OPENCODE_RESPONSES_RC, opencode-anthropic=$OPENCODE_ANTHROPIC_RC)"
exit 1
fi
diff --git a/.github/workflows/scripts/test-provider-harness.sh b/.github/workflows/scripts/test-provider-harness.sh
index 4e62cd398ef..5398dc46a89 100755
--- a/.github/workflows/scripts/test-provider-harness.sh
+++ b/.github/workflows/scripts/test-provider-harness.sh
@@ -179,6 +179,48 @@ while [ "$HARNESS_EXIT" -ne 0 ] && [ "$attempt" -le "$RERUN_ATTEMPTS" ]; do
attempt=$((attempt + 1))
done
+# Render the browsable HTML report.
+#
+# newman's own htmlextra reporter only emits one in sequential mode, and this
+# runs PARALLEL=1 (one fork per provider, no way to merge N HTML documents) - so
+# without this, the provider harness ships no HTML at all while the CLI harness
+# does. harness-viewer.mjs already renders exactly this JSON for local use, so
+# --static reuses its markup rather than growing a second renderer that can
+# drift from it.
+#
+# Best-effort: a rendering failure must not fail a passing test run.
+echo ""
+echo "๐ Rendering provider harness HTML report..."
+if node "$REPO_ROOT/tests/e2e/api/runners/harness-viewer.mjs" \
+ --report "$REPO_ROOT/tmp/newman-report.json" \
+ --failures-md "$REPO_ROOT/tmp/harness-failures.md" \
+ --token-parity-md "$REPO_ROOT/tmp/harness-token-parity.md" \
+ --static "$REPO_ROOT/tmp/provider-harness-report.html"; then
+ :
+else
+ echo "โ ๏ธ HTML report rendering failed; the JSON and markdown artifacts are still intact"
+ # Rendering is best effort, but the LINK to it is not: the release changelog
+ # points at provider-harness/index.html unconditionally, and the upload step
+ # publishes whatever is in this directory. Without a file here that link 404s
+ # in public release notes. A stub that points at the markdown siblings keeps
+ # the notes honest about what happened and still reaches the real data.
+ cat > "$REPO_ROOT/tmp/provider-harness-report.html" <<'FALLBACK_HTML'
+
+
+
Bifrost Provider Harness Report
+
+
Provider harness report unavailable
+
The harness ran and its results were collected, but rendering this HTML view
+failed. The underlying data is unaffected and published alongside this page:
The full newman-report.json is attached to the release workflow run as an artifact.
+FALLBACK_HTML
+fi
+
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$REPO_ROOT/tmp/harness-failures.md" ]; then
{
echo "## Provider harness failure breakdown"
diff --git a/core/providers/anthropic/reasoningreplay_test.go b/core/providers/anthropic/reasoningreplay_test.go
new file mode 100644
index 00000000000..597b76c17d6
--- /dev/null
+++ b/core/providers/anthropic/reasoningreplay_test.go
@@ -0,0 +1,131 @@
+package anthropic
+
+import (
+ "testing"
+
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/stretchr/testify/require"
+)
+
+// convertBifrostReasoningToAnthropicThinking's INNER branches are already
+// hardened against this defect class -- they gate on len(Summary) > 0 rather
+// than nil, and the summary and encrypted arms are deliberately independent ifs
+// so a reasoning item carrying both survives whole. The comments there spell
+// that reasoning out.
+//
+// The outer `else if` defeated it. `msg.Content != nil && msg.Content.ContentBlocks
+// != nil` is true for a NON-NIL BUT EMPTY slice, so such a message took the
+// content-block branch, emitted nothing, and never reached the hardened code
+// below. Same shape as the Bedrock and Cohere defects: data dropped silently,
+// no error, the model simply loses its prior reasoning.
+func TestConvertBifrostReasoningToAnthropicThinkingFallsThrough(t *testing.T) {
+ const encrypted = "EqQBCgIYAhIM...fixture"
+ ctx := &schemas.BifrostContext{}
+
+ tests := []struct {
+ name string
+ msg *schemas.ResponsesMessage
+ wantThinking []string // Thinking texts expected, in order
+ wantRedacted []string // redacted_thinking Data expected
+ wantTotalCount int
+ }{
+ {
+ // The regression: empty-but-non-nil content blocks must not shadow
+ // the encrypted payload sitting in ResponsesReasoning.
+ name: "empty content blocks does not shadow encrypted content",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{}},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantRedacted: []string{encrypted},
+ wantTotalCount: 1,
+ },
+ {
+ // Content blocks present but none of them reasoning: the summary is
+ // the only reasoning available and must not be lost.
+ name: "content blocks without reasoning falls back to summary",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ {Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("not reasoning")},
+ }},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "visible reasoning"}},
+ },
+ },
+ wantThinking: []string{"visible reasoning"},
+ wantTotalCount: 1,
+ },
+ {
+ // Already correct today: the inner independent-ifs keep both halves.
+ name: "summary and encrypted content both survive",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "visible reasoning"}},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantThinking: []string{"visible reasoning"},
+ wantRedacted: []string{encrypted},
+ wantTotalCount: 2,
+ },
+ {
+ // Already correct today: real content blocks take precedence and the
+ // fallback must NOT also fire, or the reasoning would be duplicated.
+ name: "content blocks with reasoning do not also emit the fallback",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ {Type: schemas.ResponsesOutputMessageContentTypeReasoning, Text: schemas.Ptr("step by step")},
+ }},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "should not appear"}},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantThinking: []string{"step by step"},
+ wantTotalCount: 1,
+ },
+ {
+ name: "no reasoning at all",
+ msg: &schemas.ResponsesMessage{Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning)},
+ wantTotalCount: 0,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ blocks := convertBifrostReasoningToAnthropicThinking(ctx, tc.msg, schemas.OpenAI, "gpt-5.5")
+ require.Len(t, blocks, tc.wantTotalCount)
+
+ var thinking, redacted []string
+ for i, block := range blocks {
+ switch block.Type {
+ case AnthropicContentBlockTypeThinking:
+ require.NotNil(t, block.Thinking, "block %d: thinking block with nil text", i)
+ // The Agent SDK requires the signature field to be present on
+ // every thinking block, even when there is nothing to embed.
+ require.NotNil(t, block.Signature, "block %d: thinking block with nil signature", i)
+ thinking = append(thinking, *block.Thinking)
+ case AnthropicContentBlockTypeRedactedThinking:
+ require.NotNil(t, block.Data, "block %d: redacted block with nil data", i)
+ redacted = append(redacted, *block.Data)
+ }
+ }
+ require.Equal(t, tc.wantThinking, nilIfEmpty(thinking), "thinking blocks")
+ require.Equal(t, tc.wantRedacted, nilIfEmpty(redacted), "redacted_thinking blocks")
+ })
+ }
+}
+
+func nilIfEmpty(s []string) []string {
+ if len(s) == 0 {
+ return nil
+ }
+ return s
+}
diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go
index b1d3d44fc7a..3c5a75f06c8 100644
--- a/core/providers/anthropic/responses.go
+++ b/core/providers/anthropic/responses.go
@@ -6212,7 +6212,13 @@ func convertBifrostReasoningToAnthropicThinking(ctx *schemas.BifrostContext, msg
var thinkingBlocks []AnthropicContentBlock
embedID := providerUtils.ShouldEmbedReasoningItemID(ctx, sourceProvider, model)
- if msg.Content != nil && msg.Content.ContentBlocks != nil {
+ // Track whether the content blocks actually yielded reasoning rather than
+ // branching on Content being non-nil. `ContentBlocks != nil` is true for an
+ // EMPTY but non-nil slice -- and for one holding only non-reasoning blocks --
+ // so an else-if here shadowed the carefully hardened fallback below and
+ // dropped the replayed reasoning without a word.
+ emittedFromContentBlocks := false
+ if msg.Content != nil {
for _, block := range msg.Content.ContentBlocks {
if block.Type == schemas.ResponsesOutputMessageContentTypeReasoning && block.Text != nil {
// signature is required by the Agent SDK; converted (non-Anthropic) reasoning
@@ -6231,9 +6237,11 @@ func convertBifrostReasoningToAnthropicThinking(ctx *schemas.BifrostContext, msg
Signature: signature,
}
thinkingBlocks = append(thinkingBlocks, thinkingBlock)
+ emittedFromContentBlocks = true
}
}
- } else if msg.ResponsesReasoning != nil {
+ }
+ if !emittedFromContentBlocks && msg.ResponsesReasoning != nil {
// Redacted-only reasoning items carry an EMPTY (non-nil) summary list next
// to encrypted_content, in both the streaming and non-streaming converters,
// so gate on the list having entries rather than on nil: a nil-check sends
diff --git a/core/providers/bedrock/reasoning_replay_test.go b/core/providers/bedrock/reasoning_replay_test.go
index 0f3b13902f2..72f90acaba9 100644
--- a/core/providers/bedrock/reasoning_replay_test.go
+++ b/core/providers/bedrock/reasoning_replay_test.go
@@ -1,17 +1,217 @@
package bedrock
import (
+ "context"
"testing"
+ "github.com/bytedance/sonic"
"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
)
-// TestConvertBifrostReasoningToBedrockReasoningEncryptedContent verifies that
-// encrypted reasoning replay data survives cross-provider translation into the
-// Bedrock reasoning signature field. Responses reasoning items use an empty,
-// non-nil Summary alongside EncryptedContent, so the converter must not treat
-// the empty slice as visible reasoning and silently drop the replay payload.
+// Bedrock Converse requires reasoningContent.reasoningText.text on every reasoning
+// block. Replaying reasoning history to Anthropic-on-Bedrock over the Responses
+// path returns, once per prior assistant turn:
+//
+// N validation errors detected: Value at 'messages.2.member.content.1.member.
+// reasoningContent.reasoningText.text' failed to satisfy constraint:
+// Member must not be null
+//
+// The tests below pin the invariant that prevents it. They are deliberately
+// written against the serialised wire form as well as the struct, because
+// BedrockReasoningContentText.Text is `*string json:"text,omitempty"` -- a nil
+// Text does not serialise as `"text":null`, it vanishes from the request
+// entirely, which is what made this defect invisible to every struct-level check.
+
+// reasoningTextInvariant asserts the one rule that matters: no block leaves this
+// converter without a Text. Run over every case's output, not just the ones the
+// case author thought to check.
+func reasoningTextInvariant(t *testing.T, blocks []BedrockContentBlock) {
+ t.Helper()
+ for i, block := range blocks {
+ require.NotNil(t, block.ReasoningContent, "block %d: nil ReasoningContent", i)
+ require.NotNil(t, block.ReasoningContent.ReasoningText, "block %d: nil ReasoningText", i)
+ require.NotNil(t, block.ReasoningContent.ReasoningText.Text,
+ "block %d: nil Text -- Bedrock rejects this with \"reasoningContent.reasoningText.text ... Member must not be null\"", i)
+ }
+}
+
+func reasoningTextBlock(text string, signature *string) schemas.ResponsesMessageContentBlock {
+ block := schemas.ResponsesMessageContentBlock{
+ Type: schemas.ResponsesOutputMessageContentTypeReasoning,
+ Text: &text,
+ }
+ block.Signature = signature
+ return block
+}
+
+func TestConvertBifrostReasoningToBedrockReasoning(t *testing.T) {
+ signature := "EqQBCgIYAhIM...fixture"
+
+ tests := []struct {
+ name string
+ msg *schemas.ResponsesMessage
+ wantBlocks int
+ wantTexts []string
+ wantSignatures []*string
+ }{
+ {
+ // The defect. This is exactly what the STREAMING ingress path emits
+ // (responses.go, output_item.added: Summary is a non-nil empty slice and
+ // EncryptedContent carries the Bedrock reasoning signature), so a client
+ // replaying what Bifrost itself streamed lands here.
+ name: "streaming shape: empty summary plus encrypted content",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: &signature,
+ },
+ },
+ wantBlocks: 1,
+ wantTexts: []string{""},
+ wantSignatures: []*string{&signature},
+ },
+ {
+ // Non-nil but empty ContentBlocks must not shadow the encrypted payload.
+ // The `else if` on Content made this drop the item entirely; the sibling
+ // invoke path guards it with emittedFromContentBlocks (invoke.go).
+ name: "empty content blocks slice does not shadow encrypted content",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{}},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: &signature,
+ },
+ },
+ wantBlocks: 1,
+ wantTexts: []string{""},
+ wantSignatures: []*string{&signature},
+ },
+ {
+ // Content blocks present but none of them reasoning: the summary is the
+ // only reasoning data available and must not be lost.
+ name: "content blocks without a reasoning block falls back to summary",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ {Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("not reasoning")},
+ }},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "thinking about it"}},
+ },
+ },
+ wantBlocks: 1,
+ wantTexts: []string{"thinking about it"},
+ wantSignatures: []*string{nil},
+ },
+ {
+ // Branch A baseline -- the NON-streaming ingress shape. Bedrock returns
+ // reasoning here as content blocks with a per-block signature, which is
+ // why replaying a non-streamed turn has always worked.
+ name: "content block with text and signature",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ reasoningTextBlock("step by step", &signature),
+ }},
+ },
+ wantBlocks: 1,
+ wantTexts: []string{"step by step"},
+ wantSignatures: []*string{&signature},
+ },
+ {
+ name: "content block with empty text keeps its signature",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ reasoningTextBlock("", &signature),
+ }},
+ },
+ wantBlocks: 1,
+ wantTexts: []string{""},
+ wantSignatures: []*string{&signature},
+ },
+ {
+ name: "summary only",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "first"}, {Text: "second"}},
+ },
+ },
+ wantBlocks: 2,
+ wantTexts: []string{"first", "second"},
+ wantSignatures: []*string{nil, nil},
+ },
+ {
+ // An empty signature is not a signature. Echoing signature:"" back 400s
+ // with "This model doesn't support the ... signature field" (see
+ // reasoningSignatureForBedrock), so emitting nothing is correct.
+ name: "empty encrypted content string emits nothing",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(""),
+ },
+ },
+ wantBlocks: 0,
+ },
+ {
+ name: "no reasoning and no content",
+ msg: &schemas.ResponsesMessage{Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning)},
+ wantBlocks: 0,
+ },
+ {
+ name: "multiple content blocks preserve order",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ reasoningTextBlock("one", nil),
+ reasoningTextBlock("two", &signature),
+ }},
+ },
+ wantBlocks: 2,
+ wantTexts: []string{"one", "two"},
+ wantSignatures: []*string{nil, &signature},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ blocks := convertBifrostReasoningToBedrockReasoning(tc.msg)
+
+ require.Len(t, blocks, tc.wantBlocks)
+ reasoningTextInvariant(t, blocks)
+
+ for i := range tc.wantTexts {
+ require.Equal(t, tc.wantTexts[i], *blocks[i].ReasoningContent.ReasoningText.Text,
+ "block %d text", i)
+ }
+ for i := range tc.wantSignatures {
+ got := blocks[i].ReasoningContent.ReasoningText.Signature
+ if tc.wantSignatures[i] == nil {
+ require.Nil(t, got, "block %d expected no signature", i)
+ continue
+ }
+ require.NotNil(t, got, "block %d expected a signature", i)
+ require.Equal(t, *tc.wantSignatures[i], *got, "block %d signature", i)
+ }
+ })
+ }
+}
+
+// TestConvertBifrostReasoningToBedrockReasoningEncryptedContent keeps the original
+// test's intent -- encrypted replay data must survive translation into the Bedrock
+// signature field -- and corrects the shape it pinned.
+//
+// It previously asserted Text was nil. That is the state Bedrock rejects: `text` is
+// omitempty, so a nil pointer means the key is absent from the serialised request,
+// and Converse answers "reasoningContent.reasoningText.text ... Member must not be
+// null". Preserving the signature was right; dropping the text was not.
func TestConvertBifrostReasoningToBedrockReasoningEncryptedContent(t *testing.T) {
encryptedContent := "EqQBCgIYAhIM...fixture"
message := &schemas.ResponsesMessage{
@@ -26,6 +226,99 @@ func TestConvertBifrostReasoningToBedrockReasoningEncryptedContent(t *testing.T)
require.Len(t, blocks, 1)
require.NotNil(t, blocks[0].ReasoningContent)
require.NotNil(t, blocks[0].ReasoningContent.ReasoningText)
- require.Nil(t, blocks[0].ReasoningContent.ReasoningText.Text)
+ require.NotNil(t, blocks[0].ReasoningContent.ReasoningText.Text)
require.Equal(t, encryptedContent, *blocks[0].ReasoningContent.ReasoningText.Signature)
}
+
+// TestConvertBifrostReasoningToBedrockReasoningTextAlwaysSerialized is the
+// assertion that maps one-to-one onto the upstream error message.
+//
+// A struct-level `Text != nil` check is not sufficient evidence here: omitempty is
+// the mechanism that made this defect invisible. Only marshalling proves the key
+// reaches the wire.
+func TestConvertBifrostReasoningToBedrockReasoningTextAlwaysSerialized(t *testing.T) {
+ signature := "EqQBCgIYAhIM...fixture"
+ blocks := convertBifrostReasoningToBedrockReasoning(&schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: &signature,
+ },
+ })
+ require.Len(t, blocks, 1)
+
+ raw, err := sonic.Marshal(blocks[0])
+ require.NoError(t, err)
+
+ text := gjson.GetBytes(raw, "reasoningContent.reasoningText.text")
+ require.True(t, text.Exists(),
+ "reasoningContent.reasoningText.text missing from the serialised block; Bedrock rejects this. got: %s", raw)
+ require.Equal(t, gjson.String, text.Type, "text must serialise as a string, got: %s", raw)
+ require.True(t, gjson.GetBytes(raw, "reasoningContent.reasoningText.signature").Exists(),
+ "signature must survive alongside the text, got: %s", raw)
+}
+
+// TestStreamingReasoningReplaySerialisesForBedrock drives the full request
+// conversion, not just the reasoning helper, so it fails the same way a live
+// request does.
+//
+// The input is the assistant turn a STREAMING client replays: Bifrost's streaming
+// ingress emits a reasoning item with an empty summary and the signature in
+// encrypted_content, and a faithful client sends that straight back on the next
+// turn. Every prior assistant turn contributes one block, which is why the live
+// error count scales 1:1 with conversation depth -- so this asserts across three
+// replayed turns rather than one.
+func TestStreamingReasoningReplaySerialisesForBedrock(t *testing.T) {
+ signature := "EqQBCgIYAhIM...fixture"
+
+ var input []schemas.ResponsesMessage
+ input = append(input, schemas.ResponsesMessage{
+ Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
+ Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("Start.")},
+ })
+ for i := 0; i < 3; i++ {
+ input = append(input,
+ schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: &signature,
+ },
+ },
+ schemas.ResponsesMessage{
+ Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
+ Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("Answer.")},
+ },
+ schemas.ResponsesMessage{
+ Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
+ Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("Continue.")},
+ },
+ )
+ }
+
+ messages, _, err := ConvertBifrostMessagesToBedrockMessages(context.Background(), input, false)
+ require.NoError(t, err)
+
+ raw, err := sonic.Marshal(messages)
+ require.NoError(t, err)
+
+ reasoningBlocks := gjson.GetBytes(raw, `#.content.#(reasoningContent)#`)
+ require.True(t, reasoningBlocks.Exists(), "no reasoning survived the conversion: %s", raw)
+
+ var seen int
+ gjson.GetBytes(raw, "#.content").ForEach(func(_, content gjson.Result) bool {
+ content.ForEach(func(_, block gjson.Result) bool {
+ reasoning := block.Get("reasoningContent.reasoningText")
+ if !reasoning.Exists() {
+ return true
+ }
+ seen++
+ require.True(t, reasoning.Get("text").Exists(),
+ "replayed reasoning block %d serialised without a text key -- this is the live 400: %s", seen, reasoning.Raw)
+ return true
+ })
+ return true
+ })
+ require.Equal(t, 3, seen, "expected one reasoning block per replayed assistant turn, got %d: %s", seen, raw)
+}
diff --git a/core/providers/bedrock/reasoningreplayaudit_test.go b/core/providers/bedrock/reasoningreplayaudit_test.go
new file mode 100644
index 00000000000..59bdfb64c7c
--- /dev/null
+++ b/core/providers/bedrock/reasoningreplayaudit_test.go
@@ -0,0 +1,160 @@
+package bedrock
+
+import (
+ "context"
+ "testing"
+
+ "github.com/bytedance/sonic"
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+// bedrockReasoningBlocksForChatMessage runs the real chat-completions converter
+// and returns the reasoning blocks it produced.
+func bedrockReasoningBlocksForChatMessage(t *testing.T, msg schemas.ChatMessage) []BedrockContentBlock {
+ t.Helper()
+ converted, err := convertMessage(context.Background(), msg)
+ require.NoError(t, err)
+ var blocks []BedrockContentBlock
+ for _, block := range converted.Content {
+ if block.ReasoningContent != nil {
+ blocks = append(blocks, block)
+ }
+ }
+ return blocks
+}
+
+// invokeThinkingBlocksFor runs the real invoke converter and returns the
+// thinking blocks it produced.
+func invokeThinkingBlocksFor(t *testing.T, item *schemas.ResponsesMessage) []BedrockInvokeMessagesContentBlock {
+ t.Helper()
+ resp := &schemas.BifrostResponsesResponse{Output: []schemas.ResponsesMessage{*item}}
+ converted := toBedrockInvokeAnthropicResponse(resp, "global.anthropic.claude-sonnet-5")
+ require.NotNil(t, converted)
+ return converted.Content
+}
+
+// An audit for the defect fixed in convertBifrostReasoningToBedrockReasoning
+// found the same shape on Bedrock's OTHER two request paths. Same struct, same
+// omitempty, same upstream rejection -- only the entry point differs, so fixing
+// the Responses converter alone left two thirds of the surface broken:
+//
+// Responses convertBifrostReasoningToBedrockReasoning (fixed)
+// ChatCompletion convertMessage -> utils.go (this file)
+// Invoke toBedrockInvokeAnthropicResponse (this file)
+//
+// Bedrock requires the thinking text on every reasoning block. Because the
+// fields are omitempty, omitting it does not send an explicit null -- the key
+// vanishes and the request is rejected before a token is read.
+
+// TestChatReasoningDetailsAlwaysSerialiseText covers the chat-completions path.
+//
+// The trigger is Bifrost's own streaming ingress: on a signature delta it emits
+// a ChatReasoningDetails with Signature set and Text nil (see chat.go's
+// reasoningContentDelta handling). A client replaying that assistant turn sends
+// it straight back, and utils.go copies detail.Text through unguarded -- so a
+// nil Text becomes a reasoningText block with no text key at all.
+func TestChatReasoningDetailsAlwaysSerialiseText(t *testing.T) {
+ signature := "EqQBCgIYAhIM...fixture"
+
+ for _, tc := range []struct {
+ name string
+ detail schemas.ChatReasoningDetails
+ }{
+ {
+ // Exactly what the streaming path emits for a signature-only delta.
+ name: "signature with nil text",
+ detail: schemas.ChatReasoningDetails{
+ Index: 0, Type: schemas.BifrostReasoningDetailsTypeText, Signature: &signature,
+ },
+ },
+ {
+ name: "signature with empty text",
+ detail: schemas.ChatReasoningDetails{
+ Index: 0, Type: schemas.BifrostReasoningDetailsTypeText,
+ Text: schemas.Ptr(""), Signature: &signature,
+ },
+ },
+ {
+ name: "text and signature",
+ detail: schemas.ChatReasoningDetails{
+ Index: 0, Type: schemas.BifrostReasoningDetailsTypeText,
+ Text: schemas.Ptr("step by step"), Signature: &signature,
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ msg := schemas.ChatMessage{
+ Role: schemas.ChatMessageRoleAssistant,
+ ChatAssistantMessage: &schemas.ChatAssistantMessage{
+ ReasoningDetails: []schemas.ChatReasoningDetails{tc.detail},
+ },
+ }
+
+ blocks := bedrockReasoningBlocksForChatMessage(t, msg)
+ require.NotEmpty(t, blocks, "reasoning detail produced no Bedrock block at all")
+
+ for i, block := range blocks {
+ if block.ReasoningContent == nil {
+ continue
+ }
+ require.NotNil(t, block.ReasoningContent.ReasoningText, "block %d", i)
+ require.NotNil(t, block.ReasoningContent.ReasoningText.Text,
+ "block %d: nil Text -- Bedrock rejects this with \"reasoningContent.reasoningText.text ... Member must not be null\"", i)
+
+ raw, err := sonic.Marshal(block)
+ require.NoError(t, err)
+ require.True(t, gjson.GetBytes(raw, "reasoningContent.reasoningText.text").Exists(),
+ "text key missing from the serialised block: %s", raw)
+ }
+ })
+ }
+}
+
+// TestInvokeThinkingAlwaysSerialised covers the invoke path.
+//
+// BedrockInvokeMessagesContentBlock.Thinking is `string json:"thinking,omitempty"`,
+// so an empty string is indistinguishable from an absent one on the wire. The
+// converter deliberately allows an empty text with a real signature in order to
+// preserve the replay token -- which produces {"type":"thinking","signature":...}
+// with no thinking key.
+//
+// Bifrost's own re-ingest proves the shape is unusable: invoke.go's decoder
+// returns nil when Thinking is absent, silently dropping the whole block.
+func TestInvokeThinkingAlwaysSerialised(t *testing.T) {
+ signature := "EqQBCgIYAhIM...fixture"
+
+ // Both fields set, which is how a reasoning item actually arrives: the
+ // converter gates the whole branch on ResponsesReasoning != nil and then
+ // reads the content blocks.
+ block := schemas.ResponsesMessageContentBlock{
+ Type: schemas.ResponsesOutputMessageContentTypeReasoning,
+ Text: schemas.Ptr(""),
+ }
+ block.Signature = &signature
+
+ blocks := invokeThinkingBlocksFor(t, &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{block}},
+ ResponsesReasoning: &schemas.ResponsesReasoning{Summary: []schemas.ResponsesReasoningSummary{}},
+ })
+
+ require.NotEmpty(t, blocks, "signature-bearing reasoning produced no thinking block")
+ inspected := 0
+ for i, block := range blocks {
+ if block.Type != "thinking" {
+ continue
+ }
+ inspected++
+ raw, err := sonic.Marshal(block)
+ require.NoError(t, err)
+ require.True(t, gjson.GetBytes(raw, "thinking").Exists(),
+ "block %d: thinking key missing, so re-ingest drops the block entirely: %s", i, raw)
+ }
+ // NotEmpty above only proves SOME block came back. If the converter stops
+ // emitting thinking blocks but still emits something else, every iteration
+ // takes the continue and the assertion never runs - so the test would report
+ // success for precisely the regression it exists to catch.
+ require.NotZero(t, inspected, "no thinking block was inspected, so nothing above was actually asserted")
+}
diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go
index 5288e23a9c2..48d89d5a9eb 100644
--- a/core/providers/bedrock/responses.go
+++ b/core/providers/bedrock/responses.go
@@ -4577,46 +4577,91 @@ func convertSingleBedrockMessageToBifrostMessages(ctx *schemas.BifrostContext, m
return outputMessages
}
-// convertBifrostReasoningToBedrockReasoning converts a Bifrost reasoning message to Bedrock reasoning blocks
+// convertBifrostReasoningToBedrockReasoning converts a Bifrost reasoning message to Bedrock reasoning blocks.
+//
+// Every block this emits MUST carry a non-nil Text. BedrockReasoningContentText.Text
+// is `*string json:"text,omitempty"`, so a nil pointer does not serialise as
+// `"text":null` -- the key disappears from the request entirely, and Bedrock Converse
+// answers:
+//
+// N validation errors detected: Value at 'messages.2.member.content.1.member.
+// reasoningContent.reasoningText.text' failed to satisfy constraint: Member must not be null
+//
+// once per replayed assistant turn. See reasoning_replay_test.go, which pins the
+// invariant at both the struct and the serialised-wire level.
func convertBifrostReasoningToBedrockReasoning(msg *schemas.ResponsesMessage) []BedrockContentBlock {
var reasoningBlocks []BedrockContentBlock
- if msg.Content != nil && msg.Content.ContentBlocks != nil {
+ // Track whether the content blocks actually produced reasoning rather than
+ // branching on Content being non-nil. A non-nil but empty ContentBlocks -- or
+ // one holding only non-reasoning blocks -- must fall through to
+ // ResponsesReasoning instead of shadowing it, otherwise the replayed reasoning
+ // is silently dropped. Mirrors toBedrockInvokeAnthropicResponse in invoke.go,
+ // which already guards this on the invoke path.
+ emittedFromContentBlocks := false
+ if msg.Content != nil {
for _, block := range msg.Content.ContentBlocks {
if block.Type == schemas.ResponsesOutputMessageContentTypeReasoning && block.Text != nil {
- reasoningBlock := BedrockContentBlock{
+ reasoningBlocks = append(reasoningBlocks, BedrockContentBlock{
ReasoningContent: &BedrockReasoningContent{
ReasoningText: &BedrockReasoningContentText{
Text: block.Text,
Signature: reasoningSignatureForBedrock(block.Signature),
},
},
- }
- reasoningBlocks = append(reasoningBlocks, reasoningBlock)
+ })
+ emittedFromContentBlocks = true
}
}
- } else if msg.ResponsesReasoning != nil {
- if len(msg.ResponsesReasoning.Summary) > 0 {
- for _, reasoningContent := range msg.ResponsesReasoning.Summary {
- reasoningBlock := BedrockContentBlock{
- ReasoningContent: &BedrockReasoningContent{
- ReasoningText: &BedrockReasoningContentText{
- Text: &reasoningContent.Text,
- },
- },
- }
- reasoningBlocks = append(reasoningBlocks, reasoningBlock)
- }
- } else if msg.ResponsesReasoning.EncryptedContent != nil && *msg.ResponsesReasoning.EncryptedContent != "" {
- reasoningBlock := BedrockContentBlock{
+ }
+ if emittedFromContentBlocks || msg.ResponsesReasoning == nil {
+ return reasoningBlocks
+ }
+
+ // Routed through the helper rather than read directly, so the empty-string
+ // guard matches every other signature site (see reasoningSignatureForBedrock).
+ signature := reasoningSignatureForBedrock(msg.ResponsesReasoning.EncryptedContent)
+
+ if len(msg.ResponsesReasoning.Summary) > 0 {
+ for i, reasoningContent := range msg.ResponsesReasoning.Summary {
+ text := reasoningContent.Text
+ block := BedrockContentBlock{
ReasoningContent: &BedrockReasoningContent{
- ReasoningText: &BedrockReasoningContentText{
- Signature: msg.ResponsesReasoning.EncryptedContent,
- },
+ ReasoningText: &BedrockReasoningContentText{Text: &text},
},
}
- reasoningBlocks = append(reasoningBlocks, reasoningBlock)
- }
+ // The signature goes on the first block only. Bedrock verifies it
+ // against that block's text, so repeating one signature across several
+ // summary entries would present it as signing text it never signed --
+ // and dropping it entirely, as this branch used to, loses the replay
+ // token the next turn needs.
+ if i == 0 {
+ block.ReasoningContent.ReasoningText.Signature = signature
+ }
+ reasoningBlocks = append(reasoningBlocks, block)
+ }
+ return reasoningBlocks
+ }
+
+ if signature != nil {
+ // A signature with no accompanying thinking text. This is what the
+ // streaming ingress path emits (an empty summary plus the signature in
+ // encrypted_content), so it is what a client faithfully replaying a
+ // streamed turn sends back.
+ //
+ // An empty Text is the only text available here -- the client never
+ // received the prose to replay -- but an ABSENT one is not an option: it
+ // is the difference between a request Bedrock evaluates and one it
+ // rejects outright before reading a token.
+ emptyText := ""
+ reasoningBlocks = append(reasoningBlocks, BedrockContentBlock{
+ ReasoningContent: &BedrockReasoningContent{
+ ReasoningText: &BedrockReasoningContentText{
+ Text: &emptyText,
+ Signature: signature,
+ },
+ },
+ })
}
return reasoningBlocks
diff --git a/core/providers/bedrock/types.go b/core/providers/bedrock/types.go
index f5548a22597..8827f7e259e 100644
--- a/core/providers/bedrock/types.go
+++ b/core/providers/bedrock/types.go
@@ -692,6 +692,10 @@ type BedrockInvokeMessagesResponse struct {
}
// BedrockInvokeMessagesContentBlock represents a content block in an Anthropic Messages response.
+//
+// One struct serves every block type, so each field carries omitempty to keep a
+// text block from advertising empty tool fields and vice versa. See MarshalJSON
+// for the one case where that default is wrong.
type BedrockInvokeMessagesContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
@@ -702,6 +706,28 @@ type BedrockInvokeMessagesContentBlock struct {
Signature string `json:"signature,omitempty"`
}
+// MarshalJSON forces the thinking key to be present on thinking blocks.
+//
+// A thinking block whose text is empty is a real state: a client replaying a
+// streamed assistant turn has the reasoning signature but not the prose it
+// signs. omitempty then deletes the key entirely, producing
+// {"type":"thinking","signature":"..."} -- which Bifrost's own re-ingest treats
+// as malformed and drops on the floor (invoke.go's decoder returns nil when
+// thinking is absent), silently losing the replay token the next turn needs.
+//
+// omitempty stays on the field so text and tool_use blocks are unaffected; only
+// thinking blocks are special-cased here.
+func (b BedrockInvokeMessagesContentBlock) MarshalJSON() ([]byte, error) {
+ type alias BedrockInvokeMessagesContentBlock
+ if b.Type != "thinking" || b.Thinking != "" {
+ return sonic.Marshal(alias(b))
+ }
+ return sonic.Marshal(struct {
+ alias
+ Thinking string `json:"thinking"`
+ }{alias: alias(b), Thinking: b.Thinking})
+}
+
// BedrockInvokeMessagesUsage represents token usage in an Anthropic Messages response.
type BedrockInvokeMessagesUsage struct {
InputTokens int `json:"input_tokens"`
diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go
index 54c3c0d54bf..1ba5e6f321b 100644
--- a/core/providers/bedrock/utils.go
+++ b/core/providers/bedrock/utils.go
@@ -923,10 +923,26 @@ func convertMessage(ctx context.Context, msg schemas.ChatMessage) (BedrockMessag
if msg.ChatAssistantMessage != nil && len(msg.ChatAssistantMessage.ReasoningDetails) > 0 {
for _, detail := range msg.ChatAssistantMessage.ReasoningDetails {
if detail.Type == schemas.BifrostReasoningDetailsTypeText {
+ // Text must never reach Bedrock as nil. It is
+ // `*string json:"text,omitempty"`, so a nil pointer drops the key
+ // from the request rather than sending an explicit null, and
+ // Converse rejects that with "reasoningContent.reasoningText.text
+ // ... Member must not be null".
+ //
+ // This is reachable from Bifrost's own output: the streaming
+ // ingress emits a reasoning detail carrying only a Signature on a
+ // signature delta, and a client replaying that assistant turn
+ // sends it straight back. Same defect as the Responses converter
+ // (convertBifrostReasoningToBedrockReasoning), different entry
+ // point.
+ text := detail.Text
+ if text == nil {
+ text = schemas.Ptr("")
+ }
contentBlocks = append(contentBlocks, BedrockContentBlock{
ReasoningContent: &BedrockReasoningContent{
ReasoningText: &BedrockReasoningContentText{
- Text: detail.Text,
+ Text: text,
Signature: reasoningSignatureForBedrock(detail.Signature),
},
},
diff --git a/core/providers/cohere/reasoningreplay_test.go b/core/providers/cohere/reasoningreplay_test.go
new file mode 100644
index 00000000000..a344fd3de01
--- /dev/null
+++ b/core/providers/cohere/reasoningreplay_test.go
@@ -0,0 +1,306 @@
+package cohere
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/stretchr/testify/require"
+)
+
+// Replayed reasoning must survive translation into Cohere thinking blocks.
+//
+// Two independent bugs made it vanish instead, both found by auditing the same
+// defect class as the Bedrock reasoning-replay 400:
+//
+// 1. `if Summary != nil` is true for an EMPTY but non-nil slice, so the loop ran
+// zero times and the encrypted-content fallback below it was unreachable --
+// dead code. Every construction site of schemas.ResponsesReasoning in this
+// codebase sets `Summary: []schemas.ResponsesReasoningSummary{}`, and the
+// field has no omitempty, so the empty-non-nil state survives a JSON round
+// trip. That branch had never once executed.
+//
+// 2. The outer `else if` on Content meant a non-nil but empty ContentBlocks --
+// or one holding only non-reasoning blocks -- emitted nothing and never fell
+// through to ResponsesReasoning.
+//
+// Both drop data silently: no error, no warning, the model simply loses its
+// prior reasoning. Cohere has no encrypted-reasoning field, so the replay lands
+// in a marked thinking block; losing it outright is strictly worse.
+func TestConvertBifrostReasoningToCohereThinking(t *testing.T) {
+ const encrypted = "EqQBCgIYAhIM...fixture"
+
+ tests := []struct {
+ name string
+ msg *schemas.ResponsesMessage
+ wantCount int
+ wantAll []string // substrings that must appear across the emitted blocks
+ }{
+ {
+ // Bug 1. The shape every provider's ingress produces.
+ name: "empty summary plus encrypted content",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantCount: 1,
+ wantAll: []string{encrypted},
+ },
+ {
+ // Bug 2. Non-nil but empty content blocks must not shadow the fallback.
+ name: "empty content blocks does not shadow encrypted content",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{}},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantCount: 1,
+ wantAll: []string{encrypted},
+ },
+ {
+ // Bug 2 again, via content blocks that carry no reasoning.
+ name: "content blocks without reasoning falls back to summary",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ {Type: schemas.ResponsesOutputMessageContentTypeText, Text: schemas.Ptr("not reasoning")},
+ }},
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "thinking about it"}},
+ },
+ },
+ wantCount: 1,
+ wantAll: []string{"thinking about it"},
+ },
+ {
+ // A summary and an encrypted payload are independent facts; keeping
+ // only one of them loses replay data the next turn needs.
+ name: "summary and encrypted content both survive",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "visible reasoning"}},
+ EncryptedContent: schemas.Ptr(encrypted),
+ },
+ },
+ wantCount: 2,
+ wantAll: []string{"visible reasoning", encrypted},
+ },
+ {
+ name: "content blocks with reasoning",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{ContentBlocks: []schemas.ResponsesMessageContentBlock{
+ {Type: schemas.ResponsesOutputMessageContentTypeReasoning, Text: schemas.Ptr("step by step")},
+ }},
+ },
+ wantCount: 1,
+ wantAll: []string{"step by step"},
+ },
+ {
+ name: "summary only",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{{Text: "first"}, {Text: "second"}},
+ },
+ },
+ wantCount: 2,
+ wantAll: []string{"first", "second"},
+ },
+ {
+ name: "empty encrypted content emits nothing",
+ msg: &schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(""),
+ },
+ },
+ wantCount: 0,
+ },
+ {
+ name: "no reasoning at all",
+ msg: &schemas.ResponsesMessage{Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning)},
+ wantCount: 0,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ blocks := convertBifrostReasoningToCohereThinking(tc.msg)
+ require.Len(t, blocks, tc.wantCount)
+
+ var combined strings.Builder
+ for i, block := range blocks {
+ require.Equal(t, CohereContentBlockTypeThinking, block.Type, "block %d", i)
+ require.NotNil(t, block.Thinking, "block %d: nil Thinking", i)
+ combined.WriteString(*block.Thinking)
+ combined.WriteString("\n")
+ }
+ for _, want := range tc.wantAll {
+ require.Contains(t, combined.String(), want,
+ "replayed reasoning was dropped: %q missing from the emitted thinking blocks", want)
+ }
+ })
+ }
+}
+
+// The encrypted replay token has to survive a FULL round trip, not just egress.
+//
+// Cohere has no encrypted-reasoning field, so egress smuggles the token out
+// inside a marked thinking block. That is only half a round trip: on the way
+// back in, every thinking block was mapped to ordinary reasoning text, so the
+// marker reached clients as visible prose and was replayed as if the model had
+// literally thought "[ENCRYPTED_REASONING: ...]". The token that was supposed
+// to be restored to EncryptedContent was, from the provider's point of view,
+// gone - and the replay it exists to enable could never happen.
+func TestEncryptedReasoningSurvivesCohereRoundTrip(t *testing.T) {
+ const token = "ErcBCkYIBRgCKkDd2xLm...opaque"
+
+ out := convertBifrostReasoningToCohereThinking(&schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(token),
+ },
+ })
+ require.NotEmpty(t, out, "egress dropped the encrypted reasoning entirely")
+
+ // Feed exactly what egress produced back through ingress, which is what a
+ // client replaying the turn actually does.
+ back := convertSingleCohereMessageToBifrostMessages(&CohereMessage{
+ Role: "assistant",
+ Content: &CohereMessageContent{BlocksContent: out},
+ }, true)
+ require.NotEmpty(t, back, "ingress produced no messages")
+
+ var reasoning *schemas.ResponsesMessage
+ for i := range back {
+ if back[i].Type != nil && *back[i].Type == schemas.ResponsesMessageTypeReasoning {
+ reasoning = &back[i]
+ break
+ }
+ }
+ require.NotNil(t, reasoning, "no reasoning message came back")
+ require.NotNil(t, reasoning.ResponsesReasoning, "reasoning message carries no ResponsesReasoning")
+ require.NotNil(t, reasoning.ResponsesReasoning.EncryptedContent,
+ "the replay token was not restored to EncryptedContent, so the next turn cannot replay it")
+ require.Equal(t, token, *reasoning.ResponsesReasoning.EncryptedContent)
+
+ // And it must not ALSO surface as visible reasoning text: the marker is a
+ // transport detail, and leaking it to clients means replaying it as content.
+ if reasoning.Content != nil {
+ for _, b := range reasoning.Content.ContentBlocks {
+ if b.Text != nil {
+ require.NotContains(t, *b.Text, "ENCRYPTED_REASONING",
+ "the transport marker leaked into visible reasoning text")
+ }
+ }
+ }
+}
+
+// The combined shape - content-block reasoning AND an encrypted token on the
+// same message - is not hypothetical: it is exactly what this file's own ingress
+// path now produces, since restoring EncryptedContent leaves the visible
+// reasoning blocks in place. Returning early once the content blocks yielded
+// reasoning drops the token on the very next turn, which is the turn the token
+// exists for. The token-only round-trip test above stayed green throughout,
+// because it never exercised both at once.
+func TestEncryptedTokenSurvivesAlongsideContentBlockReasoning(t *testing.T) {
+ const token = "ErcBCkYIBRgCKkDd2xLm...combined"
+
+ blocks := convertBifrostReasoningToCohereThinking(&schemas.ResponsesMessage{
+ Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
+ Content: &schemas.ResponsesMessageContent{
+ ContentBlocks: []schemas.ResponsesMessageContentBlock{{
+ Type: schemas.ResponsesOutputMessageContentTypeReasoning,
+ Text: schemas.Ptr("Thinking it through."),
+ }},
+ },
+ ResponsesReasoning: &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: schemas.Ptr(token),
+ },
+ })
+
+ var sawText, sawMarker bool
+ for _, b := range blocks {
+ if b.Thinking == nil {
+ continue
+ }
+ if *b.Thinking == "Thinking it through." {
+ sawText = true
+ }
+ if enc, ok := parseEncryptedReasoning(*b.Thinking); ok && enc == token {
+ sawMarker = true
+ }
+ }
+
+ require.True(t, sawText, "visible reasoning text was dropped")
+ require.True(t, sawMarker,
+ "the encrypted replay token was dropped because the content blocks already yielded reasoning")
+}
+
+// The full replay path, not just one half of it.
+//
+// The combined-shape test above starts from a hand-built message and only proves egress emits
+// both parts. What actually happens on turn two of a replay is: a Cohere response is INGESTED
+// into a Bifrost message, and that message is then converted back out. Only the round trip shows
+// whether the two halves agree - an ingress that restores the token into a field egress skips, or
+// an egress that drops what ingress carefully preserved, both look correct in isolation.
+func TestMixedReasoningSurvivesIngressThenEgress(t *testing.T) {
+ const token = "ErcBCkYIBRgCKkDd2xLm...mixed"
+ const visible = "Working through the constraints."
+
+ // What Cohere sends back when the previous turn carried both: ordinary thinking plus the
+ // marked block Bifrost wrote on the way out.
+ marker := formatEncryptedReasoning(token)
+ visibleText := visible
+ incoming := []CohereContentBlock{
+ {Type: CohereContentBlockTypeThinking, Thinking: &visibleText},
+ {Type: CohereContentBlockTypeThinking, Thinking: &marker},
+ }
+
+ back := convertSingleCohereMessageToBifrostMessages(&CohereMessage{
+ Role: "assistant",
+ Content: &CohereMessageContent{BlocksContent: incoming},
+ }, true)
+
+ var reasoning *schemas.ResponsesMessage
+ for i := range back {
+ if back[i].Type != nil && *back[i].Type == schemas.ResponsesMessageTypeReasoning {
+ reasoning = &back[i]
+ break
+ }
+ }
+ require.NotNil(t, reasoning, "ingress produced no reasoning message")
+ require.NotNil(t, reasoning.ResponsesReasoning, "ingress dropped ResponsesReasoning")
+ require.NotNil(t, reasoning.ResponsesReasoning.EncryptedContent, "ingress did not restore the replay token")
+ require.Equal(t, token, *reasoning.ResponsesReasoning.EncryptedContent)
+
+ // Now send that exact message back out, which is what the next turn does.
+ out := convertBifrostReasoningToCohereThinking(reasoning)
+
+ var sawVisible, sawMarker bool
+ for _, b := range out {
+ if b.Thinking == nil {
+ continue
+ }
+ if *b.Thinking == visible {
+ sawVisible = true
+ }
+ if enc, ok := parseEncryptedReasoning(*b.Thinking); ok && enc == token {
+ sawMarker = true
+ }
+ }
+ require.True(t, sawVisible, "the visible reasoning text was lost across the round trip")
+ require.True(t, sawMarker,
+ "the replay token was lost across the round trip - ingress restored it but egress did not send it back")
+}
diff --git a/core/providers/cohere/responses.go b/core/providers/cohere/responses.go
index bd09878de58..1719ec5f5c1 100644
--- a/core/providers/cohere/responses.go
+++ b/core/providers/cohere/responses.go
@@ -1660,40 +1660,101 @@ func convertBifrostMessageToCohereMessage(msg *schemas.ResponsesMessage) *Cohere
func convertBifrostReasoningToCohereThinking(msg *schemas.ResponsesMessage) []CohereContentBlock {
var thinkingBlocks []CohereContentBlock
- if msg.Content != nil && msg.Content.ContentBlocks != nil {
+ // Track whether the content blocks actually yielded reasoning rather than
+ // branching on Content being non-nil. A non-nil but EMPTY ContentBlocks -- or
+ // one carrying only non-reasoning blocks -- must fall through to
+ // ResponsesReasoning instead of shadowing it, otherwise the replayed
+ // reasoning is dropped with no error and no warning.
+ emittedFromContentBlocks := false
+ if msg.Content != nil {
for _, block := range msg.Content.ContentBlocks {
if block.Type == schemas.ResponsesOutputMessageContentTypeReasoning && block.Text != nil {
- thinkingBlock := CohereContentBlock{
+ thinkingBlocks = append(thinkingBlocks, CohereContentBlock{
Type: CohereContentBlockTypeThinking,
Thinking: block.Text,
- }
- thinkingBlocks = append(thinkingBlocks, thinkingBlock)
+ })
+ emittedFromContentBlocks = true
}
}
- } else if msg.ResponsesReasoning != nil {
- if msg.ResponsesReasoning.Summary != nil {
- for _, reasoningContent := range msg.ResponsesReasoning.Summary {
- thinkingBlock := CohereContentBlock{
- Type: CohereContentBlockTypeThinking,
- Thinking: &reasoningContent.Text,
- }
- thinkingBlocks = append(thinkingBlocks, thinkingBlock)
- }
- } else if msg.ResponsesReasoning.EncryptedContent != nil {
- // Cohere doesn't have a direct equivalent to encrypted content,
- // so we'll store it as a regular thinking block with a special marker
- encryptedText := fmt.Sprintf("[ENCRYPTED_REASONING: %s]", *msg.ResponsesReasoning.EncryptedContent)
- thinkingBlock := CohereContentBlock{
+ }
+ if msg.ResponsesReasoning == nil {
+ return thinkingBlocks
+ }
+
+ // Not `emittedFromContentBlocks || ...`: the content blocks and the encrypted
+ // token are independent facts, and a message can carry both. This file's own
+ // ingress path produces exactly that shape - restoring EncryptedContent
+ // leaves the visible reasoning blocks in place - so returning early here
+ // dropped the token on the next turn, which is the turn it exists for.
+ //
+ // Only the SUMMARY is skipped when the content blocks already spoke, since
+ // those two carry the same visible text and emitting both would duplicate it.
+
+ // len(Summary), not Summary != nil. Every construction site of
+ // schemas.ResponsesReasoning in this codebase sets an empty-but-non-nil
+ // slice, and the field has no omitempty so that state survives a JSON round
+ // trip -- so the nil check was true for essentially every real message, the
+ // loop ran zero times, and the encrypted branch below was unreachable.
+ if !emittedFromContentBlocks {
+ for _, reasoningContent := range msg.ResponsesReasoning.Summary {
+ text := reasoningContent.Text
+ thinkingBlocks = append(thinkingBlocks, CohereContentBlock{
Type: CohereContentBlockTypeThinking,
- Thinking: &encryptedText,
- }
- thinkingBlocks = append(thinkingBlocks, thinkingBlock)
+ Thinking: &text,
+ })
}
}
+ // Emitted IN ADDITION to any summary, not instead of it: the visible summary
+ // and the encrypted replay token are independent facts, and the next turn
+ // needs whichever the upstream will accept. Cohere has no encrypted-reasoning
+ // field, so it travels as a marked thinking block -- imperfect, but strictly
+ // better than silently losing it.
+ if enc := msg.ResponsesReasoning.EncryptedContent; enc != nil && *enc != "" {
+ encryptedText := formatEncryptedReasoning(*enc)
+ thinkingBlocks = append(thinkingBlocks, CohereContentBlock{
+ Type: CohereContentBlockTypeThinking,
+ Thinking: &encryptedText,
+ })
+ }
+
return thinkingBlocks
}
+// Cohere has no encrypted-reasoning field, so the replay token travels as a
+// marked thinking block. The marker is a transport detail of THIS provider
+// pairing: both ends of it live here, and neither end is meaningful without the
+// other. Egress-only was the original bug - the token was written out and never
+// read back, so it reached clients as visible reasoning prose and was replayed
+// as if the model had thought the marker itself.
+const (
+ encryptedReasoningPrefix = "[ENCRYPTED_REASONING: "
+ encryptedReasoningSuffix = "]"
+)
+
+func formatEncryptedReasoning(enc string) string {
+ return encryptedReasoningPrefix + enc + encryptedReasoningSuffix
+}
+
+// parseEncryptedReasoning recovers a replay token from a thinking block, and
+// reports whether the block was a marker at all.
+//
+// Deliberately exact: only a block that is ENTIRELY the marker counts. A model
+// that merely mentions the phrase mid-sentence is writing prose, and treating
+// that as a replay token would both swallow real reasoning text and hand the
+// provider a token it never issued.
+func parseEncryptedReasoning(thinking string) (string, bool) {
+ if !strings.HasPrefix(thinking, encryptedReasoningPrefix) ||
+ !strings.HasSuffix(thinking, encryptedReasoningSuffix) {
+ return "", false
+ }
+ enc := thinking[len(encryptedReasoningPrefix) : len(thinking)-len(encryptedReasoningSuffix)]
+ if enc == "" {
+ return "", false
+ }
+ return enc, true
+}
+
// convertBifrostFunctionCallToCohereMessage converts a Bifrost function call to Cohere message
func convertBifrostFunctionCallToCohereMessage(msg *schemas.ResponsesMessage) *CohereMessage {
assistantMsg := CohereMessage{
@@ -1770,6 +1831,8 @@ func convertBifrostFunctionCallOutputToCohereMessage(msg *schemas.ResponsesMessa
func convertSingleCohereMessageToBifrostMessages(cohereMsg *CohereMessage, isOutputMessage bool) []schemas.ResponsesMessage {
var outputMessages []schemas.ResponsesMessage
var reasoningContentBlocks []schemas.ResponsesMessageContentBlock
+ // Replay token recovered from a marked thinking block, if this message carried one.
+ var encryptedReasoning string
// Handle text content first
if cohereMsg.Content != nil {
@@ -1791,6 +1854,17 @@ func convertSingleCohereMessageToBifrostMessages(cohereMsg *CohereMessage, isOut
// Convert content blocks and separate reasoning blocks
for _, block := range cohereMsg.Content.BlocksContent {
if block.Type == CohereContentBlockTypeThinking {
+ // A marker block is the encrypted replay token this
+ // provider smuggles through the thinking channel, not
+ // something the model thought. It goes back to
+ // EncryptedContent, where the next turn's egress looks for
+ // it, and is kept out of the visible reasoning text.
+ if block.Thinking != nil {
+ if enc, ok := parseEncryptedReasoning(*block.Thinking); ok {
+ encryptedReasoning = enc
+ continue
+ }
+ }
// Collect reasoning blocks to create a single reasoning message
reasoningContentBlocks = append(reasoningContentBlocks, schemas.ResponsesMessageContentBlock{
Type: schemas.ResponsesOutputMessageContentTypeReasoning,
@@ -1836,14 +1910,23 @@ func convertSingleCohereMessageToBifrostMessages(cohereMsg *CohereMessage, isOut
}
}
- // Handle reasoning blocks - prepend reasoning message if we collected any
- if len(reasoningContentBlocks) > 0 {
+ // Handle reasoning blocks - prepend reasoning message if we collected any.
+ //
+ // The encrypted token counts on its own: a turn whose only reasoning was the
+ // replay marker has no visible blocks left after it is extracted, and
+ // gating solely on the block count would drop the token entirely - the very
+ // bug this restores.
+ if len(reasoningContentBlocks) > 0 || encryptedReasoning != "" {
+ reasoning := &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ }
+ if encryptedReasoning != "" {
+ reasoning.EncryptedContent = new(encryptedReasoning)
+ }
reasoningMessage := schemas.ResponsesMessage{
- ID: new("rs_" + fmt.Sprintf("%d", time.Now().UnixNano())),
- Type: new(schemas.ResponsesMessageTypeReasoning),
- ResponsesReasoning: &schemas.ResponsesReasoning{
- Summary: []schemas.ResponsesReasoningSummary{},
- },
+ ID: new("rs_" + fmt.Sprintf("%d", time.Now().UnixNano())),
+ Type: new(schemas.ResponsesMessageTypeReasoning),
+ ResponsesReasoning: reasoning,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: reasoningContentBlocks,
},
diff --git a/core/providers/gemini/reasoningreplay_test.go b/core/providers/gemini/reasoningreplay_test.go
new file mode 100644
index 00000000000..694c0913b1b
--- /dev/null
+++ b/core/providers/gemini/reasoningreplay_test.go
@@ -0,0 +1,106 @@
+package gemini
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/maximhq/bifrost/core/schemas"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+// Gemini's streaming and non-streaming egress disagreed about whether
+// encrypted_content is already base64.
+//
+// encrypted_content is a base64 STRING; Part.ThoughtSignature is a []byte that
+// Part.MarshalJSON base64-encodes on the way out. The non-streaming converters
+// decoded first, so the wire carried base64(signature). The streaming converter
+// assigned []byte(encryptedContent) straight through, so the wire carried
+// base64(base64(signature)) -- a value Gemini cannot verify, for the same
+// conversation, depending only on whether the client streamed.
+//
+// Both paths now go through thoughtSignatureFromEncryptedContent, so they cannot
+// drift apart again.
+func TestThoughtSignatureFromEncryptedContent(t *testing.T) {
+ raw := []byte{0x01, 0x02, 0xff, 0xfe, 0x7f}
+ encoded := base64.StdEncoding.EncodeToString(raw)
+
+ t.Run("decodes to the original bytes", func(t *testing.T) {
+ require.Equal(t, raw, thoughtSignatureFromEncryptedContent(&encoded))
+ })
+
+ t.Run("serialises to single-encoded base64", func(t *testing.T) {
+ part := &Part{ThoughtSignature: thoughtSignatureFromEncryptedContent(&encoded)}
+ data, err := part.MarshalJSON()
+ require.NoError(t, err)
+
+ onWire := gjson.GetBytes(data, "thoughtSignature").String()
+ require.Equal(t, encoded, onWire,
+ "thoughtSignature must round-trip to the value the client sent, not a re-encoding of it")
+
+ // The specific corruption this guards against: passing the base64 string
+ // through as bytes yields base64(base64(sig)), which is strictly longer
+ // and decodes to the base64 TEXT rather than the signature.
+ doubled := base64.StdEncoding.EncodeToString([]byte(encoded))
+ require.NotEqual(t, doubled, onWire, "signature was double-encoded")
+ })
+
+ t.Run("rejects unusable values rather than corrupting them", func(t *testing.T) {
+ require.Nil(t, thoughtSignatureFromEncryptedContent(nil))
+ require.Nil(t, thoughtSignatureFromEncryptedContent(ptr("")))
+ // Not valid base64: dropping it beats shipping a signature Gemini will
+ // reject, and beats a partial decode.
+ require.Nil(t, thoughtSignatureFromEncryptedContent(ptr("not!valid!base64!")))
+ })
+}
+
+func ptr(s string) *string { return &s }
+
+// Gemini 3 carries thoughtSignature on the thought part itself, and requires it
+// back on replay -- there is a dedicated finish reason for its absence
+// (FinishReasonMissingThoughtSignature). The ingress converter read only
+// part.Text, so the signature never reached the client and could never be
+// replayed; a signature-only thought part produced no message at all.
+func TestThoughtPartIngressPreservesSignature(t *testing.T) {
+ raw := []byte{0x0a, 0x0b, 0x0c, 0xff}
+ encoded := base64.StdEncoding.EncodeToString(raw)
+
+ t.Run("signature survives alongside text", func(t *testing.T) {
+ msgs := responsesMessagesForThoughtPart(t, &Part{
+ Thought: true, Text: "step by step", ThoughtSignature: raw,
+ })
+ require.Len(t, msgs, 1)
+ require.NotNil(t, msgs[0].ResponsesReasoning, "signature dropped: no reasoning payload")
+ require.NotNil(t, msgs[0].ResponsesReasoning.EncryptedContent)
+ require.Equal(t, encoded, *msgs[0].ResponsesReasoning.EncryptedContent)
+ // Round trip: what egress decodes must equal what Gemini sent.
+ require.Equal(t, raw, thoughtSignatureFromEncryptedContent(msgs[0].ResponsesReasoning.EncryptedContent))
+ })
+
+ t.Run("signature-only thought part is not dropped", func(t *testing.T) {
+ msgs := responsesMessagesForThoughtPart(t, &Part{Thought: true, ThoughtSignature: raw})
+ require.Len(t, msgs, 1, "a signature-only thought part must still produce a message")
+ require.NotNil(t, msgs[0].ResponsesReasoning)
+ require.Equal(t, encoded, *msgs[0].ResponsesReasoning.EncryptedContent)
+ })
+
+ t.Run("thought part with neither text nor signature emits nothing", func(t *testing.T) {
+ require.Empty(t, responsesMessagesForThoughtPart(t, &Part{Thought: true}))
+ })
+}
+
+// responsesMessagesForThoughtPart runs the real ingress converter over a single
+// candidate holding one part, and returns the reasoning messages it produced.
+func responsesMessagesForThoughtPart(t *testing.T, part *Part) []schemas.ResponsesMessage {
+ t.Helper()
+ out := convertGeminiCandidatesToResponsesOutput([]*Candidate{
+ {Content: &Content{Parts: []*Part{part}}},
+ })
+ var reasoning []schemas.ResponsesMessage
+ for _, msg := range out {
+ if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeReasoning {
+ reasoning = append(reasoning, msg)
+ }
+ }
+ return reasoning
+}
diff --git a/core/providers/gemini/responses.go b/core/providers/gemini/responses.go
index 41e7f34053e..150ade4064e 100644
--- a/core/providers/gemini/responses.go
+++ b/core/providers/gemini/responses.go
@@ -15,6 +15,30 @@ import (
"github.com/maximhq/bifrost/core/schemas"
)
+// thoughtSignatureFromEncryptedContent converts a Responses-API
+// encrypted_content value into the raw bytes Part.ThoughtSignature expects.
+//
+// The decode is required, not cosmetic. encrypted_content is already a base64
+// STRING, while ThoughtSignature is a []byte that Part.MarshalJSON base64s on
+// the way out -- so assigning []byte(encryptedContent) directly ships
+// base64(base64(signature)) and Gemini cannot verify it. The non-streaming
+// converters always decoded first; the streaming one did not, which meant the
+// two paths silently disagreed about the encoding for the same conversation.
+//
+// Returns nil when there is nothing usable, so callers can skip the part
+// entirely rather than emit an empty signature: a malformed value is dropped
+// rather than corrupted onwards.
+func thoughtSignatureFromEncryptedContent(encryptedContent *string) []byte {
+ if encryptedContent == nil || *encryptedContent == "" {
+ return nil
+ }
+ decoded, err := base64.StdEncoding.DecodeString(*encryptedContent)
+ if err != nil || len(decoded) == 0 {
+ return nil
+ }
+ return decoded
+}
+
func (request *GeminiGenerationRequest) ToBifrostResponsesRequest(ctx *schemas.BifrostContext) *schemas.BifrostResponsesRequest {
if request == nil {
return nil
@@ -522,8 +546,8 @@ func ToGeminiResponsesResponse(bifrostResp *schemas.BifrostResponsesResponse) *G
}
}
if msg.ResponsesReasoning.EncryptedContent != nil {
- decodedSig, err := base64.StdEncoding.DecodeString(*msg.ResponsesReasoning.EncryptedContent)
- if err == nil {
+ decodedSig := thoughtSignatureFromEncryptedContent(msg.ResponsesReasoning.EncryptedContent)
+ if decodedSig != nil {
currentParts = append(currentParts, &Part{
ThoughtSignature: decodedSig,
})
@@ -849,10 +873,10 @@ func ToGeminiResponsesStreamResponse(bifrostResp *schemas.BifrostResponsesStream
// Already handled via deltas, skip
return nil
case schemas.ResponsesStreamResponseTypeOutputItemAdded:
- if bifrostResp.Item != nil && bifrostResp.Item.ResponsesReasoning != nil && bifrostResp.Item.EncryptedContent != nil {
- candidate.Content.Parts = append(candidate.Content.Parts, &Part{
- ThoughtSignature: []byte(*bifrostResp.Item.ResponsesReasoning.EncryptedContent),
- })
+ if bifrostResp.Item != nil && bifrostResp.Item.ResponsesReasoning != nil {
+ if sig := thoughtSignatureFromEncryptedContent(bifrostResp.Item.ResponsesReasoning.EncryptedContent); sig != nil {
+ candidate.Content.Parts = append(candidate.Content.Parts, &Part{ThoughtSignature: sig})
+ }
}
// Track function call metadata for later use in FunctionCallArgumentsDone
if bifrostResp.Item != nil && bifrostResp.Item.Type != nil &&
@@ -2662,20 +2686,43 @@ func convertGeminiCandidatesToResponsesOutput(candidates []*Candidate) []schemas
// Handle different types of parts
switch {
case part.Thought:
- // Thinking/reasoning message
- if part.Text != "" {
+ // Thinking/reasoning message.
+ //
+ // The signature has to come across with the text. Gemini 3 puts
+ // thoughtSignature on the thought part itself, and requires it back
+ // on replay -- there is a dedicated finish reason for its absence
+ // (FinishReasonMissingThoughtSignature). Reading only part.Text
+ // dropped it on the floor, so a client could never send it back.
+ //
+ // Emitted even when the text is empty: a signature-only thought
+ // part is a real Gemini shape, and skipping it loses the one field
+ // the next turn actually needs.
+ if part.Text != "" || len(part.ThoughtSignature) > 0 {
+ text := part.Text
msg := schemas.ResponsesMessage{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeReasoning,
- Text: &part.Text,
+ Text: &text,
},
},
},
Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning),
}
+ if len(part.ThoughtSignature) > 0 {
+ // Stored base64-encoded, which is the form
+ // encrypted_content carries on the wire and the form
+ // thoughtSignatureFromEncryptedContent decodes on the way
+ // back out -- so the round trip is symmetric by construction.
+ encoded := base64.StdEncoding.EncodeToString(part.ThoughtSignature)
+ msg.ResponsesReasoning = &schemas.ResponsesReasoning{
+ Summary: []schemas.ResponsesReasoningSummary{},
+ EncryptedContent: &encoded,
+ }
+ msg.Content.ContentBlocks[0].Signature = &encoded
+ }
messages = append(messages, msg)
}
diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json
index 195e7a12c0b..99c126b8938 100644
--- a/tests/e2e/api/collections/provider-harness.json
+++ b/tests/e2e/api/collections/provider-harness.json
@@ -48638,6 +48638,243 @@
"response": []
}
]
+ },
+ {
+ "name": "42. Cohere Encrypted Reasoning Round-Trip (PR #5982)",
+ "description": "Regression coverage for the Cohere half of PR #5982. Cohere's API has no encrypted-reasoning field, so a replay token travels out inside a thinking block marked '[ENCRYPTED_REASONING: ...]'. That was only half a round trip: egress wrote the marker, and nothing on ingress read it back, so every thinking block - marker included - was mapped to ordinary reasoning text. The token never reached ResponsesReasoning.EncryptedContent, and the marker itself leaked to clients as visible model reasoning, then got replayed as if it were content. Case 1 pins that reasoning comes back as a reasoning item and captures the turn; case 2 replays it and asserts both that the replay is accepted and that the transport marker never appears in the response.",
+ "item": [
+ {
+ "name": "cohere/command-a-reasoning /v1/responses returns reasoning + captures replay input - PR #5982",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }",
+ "pm.test('Cohere reasoning returned as a reasoning output item', function () {",
+ " pm.expect(pm.response.code, 'request failed: ' + pm.response.text()).to.be.below(400);",
+ " var j = pm.response.json();",
+ " pm.expect(j.output, 'expected output array').to.be.an('array').that.is.not.empty;",
+ " var reasoning = j.output.filter(function (o) { return o.type === 'reasoning'; });",
+ " pm.expect(reasoning.length, 'expected at least one reasoning output item').to.be.above(0);",
+ " // The replay row checks this too, but only that row - a capture response",
+ " // that leaked the marker would slip through whenever the replay response",
+ " // happened to carry no reasoning item.",
+ " pm.expect(pm.response.text(), 'the transport marker leaked into the capture response')",
+ " .to.not.include('ENCRYPTED_REASONING');",
+ "});",
+ "// Capture the exact turn to replay: the original user message plus the",
+ "// assistant output echoed verbatim. Echoing verbatim is the point - it is",
+ "// what carries any encrypted_content back out to the provider.",
+ "try {",
+ " var body = pm.response.json();",
+ " if (body.output && body.output.length) {",
+ " var input = [{ type: 'message', role: 'user', content: \"Work out 17 * 23 step by step, then give just the number.\" }]",
+ " .concat(body.output);",
+ " pm.collectionVariables.set('cohereReasoningReplayInput5982', JSON.stringify(input));",
+ " }",
+ "} catch (e) {}"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"model\": \"cohere/command-a-reasoning-08-2025\", \"input\": \"Work out 17 * 23 step by step, then give just the number.\", \"reasoning\": {\"effort\": \"medium\"}, \"max_output_tokens\": 2048}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/v1/responses",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "v1",
+ "responses"
+ ]
+ }
+ }
+ },
+ {
+ "name": "cohere/command-a-reasoning /v1/responses replay preserves encrypted reasoning - PR #5982",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "// Skip when the capture step did not run: the body placeholder stays\n// unresolved, so there is nothing meaningful to replay.",
+ "if (pm.request.body && pm.request.body.raw && pm.request.body.raw.indexOf('{{') !== -1) { return; }",
+ "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }",
+ "pm.test('Replayed Cohere reasoning is accepted', function () {",
+ " pm.expect(pm.response.code, 'replay request failed: ' + pm.response.text()).to.be.below(400);",
+ " var j = pm.response.json();",
+ " pm.expect(j.output, 'expected an output array').to.be.an('array').that.is.not.empty;",
+ "});",
+ "// The regression this folder exists for. Cohere has no encrypted-reasoning",
+ "// field, so Bifrost smuggles the replay token out inside a marked thinking",
+ "// block. Pre-fix nothing decoded that marker on the way back in, so it",
+ "// surfaced to the client as ordinary reasoning TEXT - the model appearing to",
+ "// have literally thought '[ENCRYPTED_REASONING: ...]', and the token that was",
+ "// meant to land in encrypted_content lost.",
+ "pm.test('Transport marker never surfaces as visible reasoning text', function () {",
+ " pm.expect(pm.response.text(), 'the [ENCRYPTED_REASONING: ...] transport marker leaked to the client')",
+ " .to.not.include('ENCRYPTED_REASONING');",
+ "});"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"model\": \"cohere/command-a-reasoning-08-2025\", \"input\": {{cohereReasoningReplayInput5982}}, \"reasoning\": {\"effort\": \"medium\"}, \"max_output_tokens\": 2048}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/v1/responses",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "v1",
+ "responses"
+ ]
+ }
+ }
+ }
+ ]
+ },
+ {
+ "name": "43. Gemini Reasoning Signature Replay (PR #5982)",
+ "description": "Multi-turn reasoning replay for Gemini through /v1/responses. Gemini returns a thought signature on reasoning that accompanies a function call, and requires it back unmodified when the tool result is sent - a dropped or rewritten signature is rejected on the continuation, so a converter that keeps only part of a reasoning item fails here and nowhere else. The harness had 71 reasoning rows and not one that replayed reasoning back on a second turn, which is the only shape that exercises this. Case 1 captures a reasoning + function_call turn; case 2 replays it with the tool result.",
+ "item": [
+ {
+ "name": "gemini/gemini-2.5-flash /v1/responses reasoning + tool call captures replay input - PR #5982",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }",
+ "pm.test('Gemini returns reasoning alongside the tool call', function () {",
+ " pm.expect(pm.response.code, 'request failed: ' + pm.response.text()).to.be.below(400);",
+ " var j = pm.response.json();",
+ " pm.expect(j.output, 'expected output array').to.be.an('array').that.is.not.empty;",
+ " // Asserted, not merely hoped for: the replay row skips when the capture",
+ " // stored nothing, so without this a run where Gemini did not call the",
+ " // tool passes green having tested no signature preservation at all.",
+ " var kinds = j.output.map(function (o) { return o.type; });",
+ " pm.expect(kinds, 'no function_call came back, so the replay row would silently skip').to.include('function_call');",
+ " pm.expect(kinds, 'no reasoning item came back, so there is no thought signature to preserve').to.include('reasoning');",
+ "});",
+ "// Only capture when a function call came back: the replay case is about a",
+ "// reasoning item sitting next to a function_call, which is the shape whose",
+ "// thought signature has to survive. Without one there is nothing to pin, so",
+ "// the replay skips rather than false-failing.",
+ "try {",
+ " var body = pm.response.json();",
+ " var fc = (body.output || []).filter(function (o) { return o.type === 'function_call'; })[0];",
+ " if (fc && fc.call_id) {",
+ " var input = [{ type: 'message', role: 'user', content: \"What time is it in Tokyo? Use the get_time tool, thinking it through first.\" }]",
+ " .concat(body.output)",
+ " .concat([{ type: 'function_call_output', call_id: fc.call_id, output: '{\"time\": \"21:00 JST\"}' }]);",
+ " pm.collectionVariables.set('geminiReasoningReplayInput5982', JSON.stringify(input));",
+ " var reasoning = (body.output || []).filter(function (o) { return o.type === 'reasoning'; });",
+ " pm.collectionVariables.set('geminiReasoningCount5982', String(reasoning.length));",
+ " }",
+ "} catch (e) {}"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"model\": \"gemini/gemini-2.5-flash\", \"input\": \"What time is it in Tokyo? Use the get_time tool, thinking it through first.\", \"tools\": [{\"type\": \"function\", \"name\": \"get_time\", \"description\": \"Get the current time\", \"parameters\": {\"type\": \"object\", \"properties\": {\"timezone\": {\"type\": \"string\"}}, \"required\": []}}], \"tool_choice\": {\"type\": \"function\", \"name\": \"get_time\"}, \"reasoning\": {\"effort\": \"low\"}, \"max_output_tokens\": 2048}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/v1/responses",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "v1",
+ "responses"
+ ]
+ }
+ }
+ },
+ {
+ "name": "gemini/gemini-2.5-flash /v1/responses replay preserves thought signature - PR #5982",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "// Skip when the capture step did not run: the body placeholder stays\n// unresolved, so there is nothing meaningful to replay.",
+ "if (pm.request.body && pm.request.body.raw && pm.request.body.raw.indexOf('{{') !== -1) { return; }",
+ "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }",
+ "pm.test('Replayed Gemini reasoning + tool call is accepted', function () {",
+ " var body = pm.response.text() || '';",
+ " pm.expect(body, 'Gemini rejected the replayed turn - the thought signature did not survive the round trip')",
+ " .to.not.include('thought_signature');",
+ " pm.expect(pm.response.code, 'replay request failed: ' + body).to.be.below(400);",
+ " var j = pm.response.json();",
+ " pm.expect(j.output, 'expected an output array').to.be.an('array').that.is.not.empty;",
+ "});"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"model\": \"gemini/gemini-2.5-flash\", \"input\": {{geminiReasoningReplayInput5982}}, \"tools\": [{\"type\": \"function\", \"name\": \"get_time\", \"description\": \"Get the current time\", \"parameters\": {\"type\": \"object\", \"properties\": {\"timezone\": {\"type\": \"string\"}}, \"required\": []}}], \"reasoning\": {\"effort\": \"low\"}, \"max_output_tokens\": 2048}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/v1/responses",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "v1",
+ "responses"
+ ]
+ }
+ }
+ }
+ ]
}
]
}
\ No newline at end of file
diff --git a/tests/e2e/api/runners/harness-viewer.mjs b/tests/e2e/api/runners/harness-viewer.mjs
index 54d0a6d2582..f45384538d6 100644
--- a/tests/e2e/api/runners/harness-viewer.mjs
+++ b/tests/e2e/api/runners/harness-viewer.mjs
@@ -8,8 +8,9 @@
// Usage:
// node harness-viewer.mjs --report tmp/newman-report.json [--port 8090]
-import { readFileSync, existsSync } from "node:fs";
+import { readFileSync, existsSync, writeFileSync } from "node:fs";
import { readReport } from "./lib/read-report.mjs";
+import { redactItemsForPublic } from "./lib/redact-report.mjs";
import { createServer } from "node:http";
import { URL } from "node:url";
@@ -263,8 +264,14 @@ const filterInput = document.getElementById('filter');
const onlyFailed = document.getElementById('only-failed');
async function load() {
- const r = await fetch('/api/report');
- items = await r.json();
+ // Static mode inlines the report instead of serving it, so the same page
+ // works as a CI artifact opened from disk with no server behind it.
+ if (window.__STATIC_REPORT__) {
+ items = window.__STATIC_REPORT__;
+ } else {
+ const r = await fetch('/api/report');
+ items = await r.json();
+ }
document.getElementById('meta').textContent = items.length + ' requests';
renderSummary();
render();
@@ -324,11 +331,18 @@ function render() {
'' +
'
' +
'
Assertions
' + (assertions || '(none)') + '
' +
- '
' +
- '' +
- '' +
- '' +
- '
' +
+ // Resend proxies through this process, so it cannot work in static
+ // mode. Copy curl is pure client-side and stays useful offline.
+ (window.__STATIC_REPORT__
+ ? '
' +
+ '' +
+ 'Resend needs the live viewer: make run-provider-harness-test' +
+ '