Fix tool call ID normalization for cross-provider switches to Anthropic/GitHub Copilot

This commit is contained in:
Mario Zechner 2026-01-13 04:06:40 +01:00
parent 3690137ecc
commit 3c60ffa677
6 changed files with 131 additions and 7 deletions

21
Dockerfile.gh-build Normal file
View file

@ -0,0 +1,21 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y curl unzip git xz-utils ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& BUN_VERSION=1.2.20 \
&& ARCH=$(dpkg --print-architecture) \
&& if [ "$ARCH" = "amd64" ]; then BUN_ARCH="linux-x64"; else BUN_ARCH="linux-aarch64"; fi \
&& curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-${BUN_ARCH}.zip" -o /tmp/bun.zip \
&& unzip /tmp/bun.zip -d /tmp \
&& mv /tmp/bun-${BUN_ARCH}/bun /usr/local/bin/bun \
&& chmod +x /usr/local/bin/bun \
&& rm -rf /tmp/bun.zip /tmp/bun-linux-x64 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /repo
ENTRYPOINT ["/bin/bash", "-lc"]

View file

@ -1,11 +1,11 @@
import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types.js"; import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types.js";
/** /**
* Normalize tool call ID for GitHub Copilot cross-API compatibility. * Normalize tool call ID for cross-provider compatibility.
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`. * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
* Other APIs (Claude, etc.) require max 40 chars and only alphanumeric + underscore + hyphen. * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
*/ */
function normalizeCopilotToolCallId(id: string): string { function normalizeToolCallId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40); return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
} }
@ -38,11 +38,17 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
return msg; return msg;
} }
// Check if we need to normalize tool call IDs (github-copilot cross-API) // Check if we need to normalize tool call IDs
const needsToolCallIdNormalization = // Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars)
// OpenAI Responses API generates IDs with `|` and 450+ chars
// GitHub Copilot routes to Anthropic for Claude models
const targetRequiresStrictIds = model.api === "anthropic-messages" || model.provider === "github-copilot";
const crossProviderSwitch = assistantMsg.provider !== model.provider;
const copilotCrossApiSwitch =
assistantMsg.provider === "github-copilot" && assistantMsg.provider === "github-copilot" &&
model.provider === "github-copilot" && model.provider === "github-copilot" &&
assistantMsg.api !== model.api; assistantMsg.api !== model.api;
const needsToolCallIdNormalization = targetRequiresStrictIds && (crossProviderSwitch || copilotCrossApiSwitch);
// Transform message from different provider/model // Transform message from different provider/model
const transformedContent = assistantMsg.content.flatMap((block) => { const transformedContent = assistantMsg.content.flatMap((block) => {
@ -54,10 +60,10 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
text: block.thinking, text: block.thinking,
}; };
} }
// Normalize tool call IDs for github-copilot cross-API switches // Normalize tool call IDs when target API requires strict format
if (block.type === "toolCall" && needsToolCallIdNormalization) { if (block.type === "toolCall" && needsToolCallIdNormalization) {
const toolCall = block as ToolCall; const toolCall = block as ToolCall;
const normalizedId = normalizeCopilotToolCallId(toolCall.id); const normalizedId = normalizeToolCallId(toolCall.id);
if (normalizedId !== toolCall.id) { if (normalizedId !== toolCall.id) {
toolCallIdMap.set(toolCall.id, normalizedId); toolCallIdMap.set(toolCall.id, normalizedId);
return { ...toolCall, id: normalizedId }; return { ...toolCall, id: normalizedId };

1
packages/coding-agent/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.bun-build

View file

@ -33,6 +33,12 @@ function getAliases(): Record<string, string> {
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageIndex = path.resolve(__dirname, "../..", "index.js"); const packageIndex = path.resolve(__dirname, "../..", "index.js");
// Debug: log what we're resolving
if (process.env.DEBUG_EXTENSIONS) {
console.error("[DEBUG] import.meta.url:", import.meta.url);
console.error("[DEBUG] __dirname:", __dirname);
}
const typeboxEntry = require.resolve("@sinclair/typebox"); const typeboxEntry = require.resolve("@sinclair/typebox");
const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, "");
@ -43,6 +49,11 @@ function getAliases(): Record<string, string> {
"@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"),
"@sinclair/typebox": typeboxRoot, "@sinclair/typebox": typeboxRoot,
}; };
if (process.env.DEBUG_EXTENSIONS) {
console.error("[DEBUG] aliases:", JSON.stringify(_aliases, null, 2));
}
return _aliases; return _aliases;
} }

40
scripts/gh-build-docker.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
set -euo pipefail
echo "=== Versions ==="
node --version
bun --version
npm --version
echo "=== Install dependencies ==="
npm ci
echo "=== Install cross-platform bindings ==="
npm install --no-save --force \
@mariozechner/clipboard-darwin-arm64@0.3.0 \
@mariozechner/clipboard-darwin-x64@0.3.0 \
@mariozechner/clipboard-linux-x64-gnu@0.3.0 \
@mariozechner/clipboard-linux-arm64-gnu@0.3.0 \
@mariozechner/clipboard-win32-x64-msvc@0.3.0
npm install --no-save --force \
@img/sharp-darwin-arm64@0.34.5 \
@img/sharp-darwin-x64@0.34.5 \
@img/sharp-linux-x64@0.34.5 \
@img/sharp-linux-arm64@0.34.5 \
@img/sharp-win32-x64@0.34.5 \
@img/sharp-libvips-darwin-arm64@1.2.4 \
@img/sharp-libvips-darwin-x64@1.2.4 \
@img/sharp-libvips-linux-x64@1.2.4 \
@img/sharp-libvips-linux-arm64@1.2.4
echo "=== Build all packages ==="
npm run build
echo "=== Build darwin-arm64 binary ==="
mkdir -p /repo/.tmp
cd packages/coding-agent
bun build --compile --target=bun-darwin-arm64 ./dist/cli.js --outfile /repo/.tmp/pi-darwin-arm64
echo "=== Done ==="
ls -la /repo/.tmp/pi-darwin-arm64

45
scripts/test-gh-build.sh Normal file
View file

@ -0,0 +1,45 @@
#!/bin/bash
set -e
# Simulate GH Actions build locally
cd /Users/badlogic/workspaces/pi-mono
echo "=== Cleaning node_modules ==="
rm -rf node_modules packages/*/node_modules
echo "=== npm ci ==="
npm ci
echo "=== Install cross-platform bindings (like GH Actions) ==="
npm install --no-save --force \
@mariozechner/clipboard-darwin-arm64@0.3.0 \
@mariozechner/clipboard-darwin-x64@0.3.0 \
@mariozechner/clipboard-linux-x64-gnu@0.3.0 \
@mariozechner/clipboard-linux-arm64-gnu@0.3.0 \
@mariozechner/clipboard-win32-x64-msvc@0.3.0
npm install --no-save --force \
@img/sharp-darwin-arm64@0.34.5 \
@img/sharp-darwin-x64@0.34.5 \
@img/sharp-linux-x64@0.34.5 \
@img/sharp-linux-arm64@0.34.5 \
@img/sharp-win32-x64@0.34.5 \
@img/sharp-libvips-darwin-arm64@1.2.4 \
@img/sharp-libvips-darwin-x64@1.2.4 \
@img/sharp-libvips-linux-x64@1.2.4 \
@img/sharp-libvips-linux-arm64@1.2.4
echo "=== Build all packages ==="
npm run build
echo "=== Build binary with cross-compile flag ==="
cd packages/coding-agent
bun build --compile --target=bun-darwin-arm64 ./dist/cli.js --outfile /tmp/pi-gh-sim/pi
cp package.json /tmp/pi-gh-sim/
echo "=== Test the binary ==="
/tmp/pi-gh-sim/pi -e /Users/badlogic/workspaces/pi-doom --help 2>&1 | head -5
echo "=== Binary size ==="
ls -la /tmp/pi-gh-sim/pi