@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed Anthropic SSE parsing to ignore unknown proxy events such as OpenAI-style `done` terminators ([#3708](https://github.com/badlogic/pi-mono/issues/3708)).
|
||||||
- Fixed OpenAI-compatible prompt cache tests to cover proxies that explicitly disable long cache retention.
|
- Fixed OpenAI-compatible prompt cache tests to cover proxies that explicitly disable long cache retention.
|
||||||
- Stopped sending `tools: []` on OpenAI-compatible, Anthropic, OpenAI Responses, OpenAI Codex Responses, and Azure OpenAI Responses requests when no tools are active (e.g. `pi --no-tools`). DashScope/Aliyun Qwen (OpenAI-compatible) rejects empty tools arrays with `"[] is too short - 'tools'"` (HTTP 400); the field is now omitted unless the conversation has tool history (the existing LiteLLM/Anthropic-proxy workaround).
|
- Stopped sending `tools: []` on OpenAI-compatible, Anthropic, OpenAI Responses, OpenAI Codex Responses, and Azure OpenAI Responses requests when no tools are active (e.g. `pi --no-tools`). DashScope/Aliyun Qwen (OpenAI-compatible) rejects empty tools arrays with `"[] is too short - 'tools'"` (HTTP 400); the field is now omitted unless the conversation has tool history (the existing LiteLLM/Anthropic-proxy workaround).
|
||||||
- Fixed `supportsXhigh()` to recognize DeepSeek V4 Pro, preserving `xhigh` reasoning requests so they map to DeepSeek's `max` effort ([#3662](https://github.com/badlogic/pi-mono/issues/3662))
|
- Fixed `supportsXhigh()` to recognize DeepSeek V4 Pro, preserving `xhigh` reasoning requests so they map to DeepSeek's `max` effort ([#3662](https://github.com/badlogic/pi-mono/issues/3662))
|
||||||
|
|||||||
@@ -237,6 +237,15 @@ interface SseDecoderState {
|
|||||||
raw: string[];
|
raw: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet<string> = new Set([
|
||||||
|
"message_start",
|
||||||
|
"message_delta",
|
||||||
|
"message_stop",
|
||||||
|
"content_block_start",
|
||||||
|
"content_block_delta",
|
||||||
|
"content_block_stop",
|
||||||
|
]);
|
||||||
|
|
||||||
function flushSseEvent(state: SseDecoderState): ServerSentEvent | null {
|
function flushSseEvent(state: SseDecoderState): ServerSentEvent | null {
|
||||||
if (!state.event && state.data.length === 0) {
|
if (!state.event && state.data.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -376,14 +385,14 @@ async function* iterateAnthropicEvents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for await (const sse of iterateSseMessages(response.body, signal)) {
|
for await (const sse of iterateSseMessages(response.body, signal)) {
|
||||||
if (!sse.event || sse.event === "ping") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sse.event === "error") {
|
if (sse.event === "error") {
|
||||||
throw new Error(sse.data);
|
throw new Error(sse.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ANTHROPIC_MESSAGE_EVENTS.has(sse.event ?? "")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
yield parseJsonWithRepair<RawMessageStreamEvent>(sse.data);
|
yield parseJsonWithRepair<RawMessageStreamEvent>(sse.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -13,6 +13,61 @@ function createSseResponse(events: Array<{ event: string; data: string }>): Resp
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const minimalAnthropicEvents = [
|
||||||
|
{
|
||||||
|
event: "message_start",
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
id: "msg_test",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "content_block_start",
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "text", text: "" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "content_block_delta",
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "text_delta", text: "Hello" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "content_block_stop",
|
||||||
|
data: JSON.stringify({ type: "content_block_stop", index: 0 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "message_delta",
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "message_delta",
|
||||||
|
delta: { stop_reason: "end_turn" },
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 5,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "message_stop",
|
||||||
|
data: JSON.stringify({ type: "message_stop" }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function createFakeAnthropicClient(response: Response): Anthropic {
|
function createFakeAnthropicClient(response: Response): Anthropic {
|
||||||
return {
|
return {
|
||||||
messages: {
|
messages: {
|
||||||
@@ -110,4 +165,25 @@ describe("Anthropic raw SSE parsing", () => {
|
|||||||
text: "col1\tcol2",
|
text: "col1\tcol2",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores unknown SSE events after message_stop", async () => {
|
||||||
|
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||||
|
const context: Context = {
|
||||||
|
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
const response = createSseResponse([
|
||||||
|
...minimalAnthropicEvents,
|
||||||
|
{ event: "done", data: "[DONE]" },
|
||||||
|
{ event: "proxy.stats", data: "not json" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stream = streamAnthropic(model, context, {
|
||||||
|
client: createFakeAnthropicClient(response),
|
||||||
|
});
|
||||||
|
const result = await stream.result();
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("stop");
|
||||||
|
expect(result.errorMessage).toBeUndefined();
|
||||||
|
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const oauthToken = await resolveApiKey("anthropic");
|
|||||||
* - Result: tool call has name "Glob" but no tool exists with that name
|
* - Result: tool call has name "Glob" but no tool exists with that name
|
||||||
*/
|
*/
|
||||||
describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => {
|
describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => {
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-20250514");
|
const model = getModel("anthropic", "claude-sonnet-4-6");
|
||||||
|
|
||||||
it("should normalize user-defined tool matching CC name (todowrite -> TodoWrite -> todowrite)", async () => {
|
it("should normalize user-defined tool matching CC name (todowrite -> TodoWrite -> todowrite)", async () => {
|
||||||
// User defines a tool named "todowrite" (lowercase)
|
// User defines a tool named "todowrite" (lowercase)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe("Context overflow error handling", () => {
|
|||||||
|
|
||||||
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic (OAuth)", () => {
|
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic (OAuth)", () => {
|
||||||
it("claude-sonnet-4 - should detect overflow via isContextOverflow", async () => {
|
it("claude-sonnet-4 - should detect overflow via isContextOverflow", async () => {
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-20250514");
|
const model = getModel("anthropic", "claude-sonnet-4-6");
|
||||||
const result = await testContextOverflow(model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
const result = await testContextOverflow(model, process.env.ANTHROPIC_OAUTH_TOKEN!);
|
||||||
logResult(result);
|
logResult(result);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||||||
],
|
],
|
||||||
api: "google-gemini-cli",
|
api: "google-gemini-cli",
|
||||||
provider: "google-antigravity",
|
provider: "google-antigravity",
|
||||||
model: "claude-sonnet-4-20250514",
|
model: "claude-sonnet-4-6",
|
||||||
usage: {
|
usage: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
@@ -146,7 +146,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||||||
],
|
],
|
||||||
api: "google-gemini-cli",
|
api: "google-gemini-cli",
|
||||||
provider: "google-antigravity",
|
provider: "google-antigravity",
|
||||||
model: "claude-sonnet-4-20250514",
|
model: "claude-sonnet-4-6",
|
||||||
usage: {
|
usage: {
|
||||||
input: 0,
|
input: 0,
|
||||||
output: 0,
|
output: 0,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe("lazy provider module loading", () => {
|
|||||||
it("loads only the Anthropic SDK when calling the root lazy wrapper", () => {
|
it("loads only the Anthropic SDK when calling the root lazy wrapper", () => {
|
||||||
const result = runProbe(`
|
const result = runProbe(`
|
||||||
const model = {
|
const model = {
|
||||||
id: "claude-sonnet-4-20250514",
|
id: "claude-sonnet-4-6",
|
||||||
name: "Claude Sonnet 4",
|
name: "Claude Sonnet 4",
|
||||||
api: "anthropic-messages",
|
api: "anthropic-messages",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
@@ -92,7 +92,7 @@ describe("lazy provider module loading", () => {
|
|||||||
|
|
||||||
it("loads only the Anthropic SDK when dispatching through streamSimple", () => {
|
it("loads only the Anthropic SDK when dispatching through streamSimple", () => {
|
||||||
const result = runProbe(`
|
const result = runProbe(`
|
||||||
const model = mod.getModel("anthropic", "claude-sonnet-4-20250514");
|
const model = mod.getModel("anthropic", "claude-sonnet-4-6");
|
||||||
const context = { messages: [{ role: "user", content: "hi" }] };
|
const context = { messages: [{ role: "user", content: "hi" }] };
|
||||||
await mod.streamSimple(model, context).result();
|
await mod.streamSimple(model, context).result();
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -879,8 +879,8 @@ describe("Generate E2E Tests", () => {
|
|||||||
// Tokens are resolved at module level (see oauthTokens above)
|
// Tokens are resolved at module level (see oauthTokens above)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
describe("Anthropic OAuth Provider (claude-sonnet-4-20250514)", () => {
|
describe("Anthropic OAuth Provider (claude-sonnet-4-6)", () => {
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-20250514");
|
const model = getModel("anthropic", "claude-sonnet-4-6");
|
||||||
|
|
||||||
it.skipIf(!anthropicOAuthToken)("should complete basic text generation", { retry: 3 }, async () => {
|
it.skipIf(!anthropicOAuthToken)("should complete basic text generation", { retry: 3 }, async () => {
|
||||||
await basicTextGeneration(model, { apiKey: anthropicOAuthToken });
|
await basicTextGeneration(model, { apiKey: anthropicOAuthToken });
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ describe("totalTokens field", () => {
|
|||||||
"claude-sonnet-4 - should return totalTokens equal to sum of components",
|
"claude-sonnet-4 - should return totalTokens equal to sum of components",
|
||||||
{ retry: 3, timeout: 60000 },
|
{ retry: 3, timeout: 60000 },
|
||||||
async () => {
|
async () => {
|
||||||
const llm = getModel("anthropic", "claude-sonnet-4-20250514");
|
const llm = getModel("anthropic", "claude-sonnet-4-6");
|
||||||
|
|
||||||
console.log(`\nAnthropic OAuth / ${llm.id}:`);
|
console.log(`\nAnthropic OAuth / ${llm.id}:`);
|
||||||
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: anthropicOAuthToken });
|
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: anthropicOAuthToken });
|
||||||
|
|||||||
Reference in New Issue
Block a user