From 58fab782f72cf45197adcec247649b9c94f90842 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sat, 19 Sep 2026 21:06:24 +0800 Subject: [PATCH 1/3] fix: omit empty tools on OpenAI compaction requests --- ...ction-openai-transport.integration.test.ts | 260 ++++++++++++++++++ release/public-source.json | 1 + test/vitest-suites.json | 1 + third_party/pi-mono/MINIMAX_CHANGES.md | 8 + .../ai/src/providers/openai-completions.ts | 5 +- .../openai-completions-empty-tools.test.ts | 2 +- 6 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts diff --git a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts new file mode 100644 index 000000000..073c5d16d --- /dev/null +++ b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts @@ -0,0 +1,260 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { streamSimple, type Context, type Model } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { compactContext } from "../../src/service/turn-system/compaction/algorithm/compact-context.js"; +import { createCheckpointSession } from "../../src/service/turn-system/compaction/execution/checkpoint-provider.js"; + +const summary = + "The synthetic file was read. Continue with the pending user request."; + +function history(model: Model<"openai-completions">): Context["messages"] { + return [ + { role: "user", content: "Read the synthetic file.", timestamp: 1 }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "read-1", + name: "read", + arguments: { path: "fixture.txt" }, + }, + ], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: "toolUse", + timestamp: 2, + usage: { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 110, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + { + role: "toolResult", + toolCallId: "read-1", + toolName: "read", + content: [{ type: "text", text: "Synthetic file contents." }], + isError: false, + timestamp: 3, + }, + ]; +} + +describe("OpenAI compaction HTTP transport", () => { + let server: ReturnType; + let model: Model<"openai-completions">; + let requests: Record[]; + let allowEmptyTools: boolean; + + beforeEach(async () => { + requests = []; + allowEmptyTools = false; + // Exercise the real SDK serializer and stream parser with a local fixture. + // This models the reported validation rule, not acceptance by a live service. + server = createServer(async (request, response) => { + let raw = ""; + for await (const chunk of request) raw += chunk; + const body = JSON.parse(raw); + requests.push(body); + if ( + !allowEmptyTools && + Array.isArray(body.tools) && + body.tools.length === 0 + ) { + response.writeHead(400, { "content-type": "application/json" }).end( + JSON.stringify({ + error: { + message: "tools must not be an empty array", + type: "invalid_request_error", + param: "tools", + }, + }), + ); + return; + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write( + `data: ${JSON.stringify({ + id: "synthetic-checkpoint", + object: "chat.completion.chunk", + model: body.model, + choices: [ + { index: 0, delta: { content: summary }, finish_reason: null }, + ], + })}\n\n`, + ); + response.end( + `data: ${JSON.stringify({ + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 15, + total_tokens: 115, + }, + })}\n\ndata: [DONE]\n\n`, + ); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + model = { + id: "fixture-model", + name: "Fixture model", + api: "openai-completions", + provider: "custom", + baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`, + reasoning: false, + input: ["text"], + contextWindow: 32_768, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + }); + + afterEach(async () => { + if (!server?.listening) return; + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + + it.each(["custom", "openai"])( + "compacts tool history through the %s provider without empty tools", + async (provider) => { + model = { ...model, provider }; + const result = await compactContext({ + history: history(model), + allowLegacyToolTrim: false, + limits: { + providerInputLimit: 20_000, + maxSerializedInputBytes: 100_000, + }, + // Admission is synthetic; checkpoint generation and HTTP transport are real. + measurePair: async () => ({ + before: { inputTokens: 1000, serializedBytes: 10_000 }, + after: { inputTokens: 100, serializedBytes: 1000 }, + }), + checkpoint: { + tokensBefore: 1000, + timestamp: 4, + open: () => + createCheckpointSession({ + model, + streamFn: streamSimple, + thinkingLevel: "off", + apiKey: "synthetic-key", + maxOutputTokens: 1024, + providerInputLimit: 20_000, + }), + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + expect(requests[0]).not.toHaveProperty("tool_choice"); + expect(requests[0].messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "assistant", + tool_calls: [expect.objectContaining({ id: "read-1" })], + }), + expect.objectContaining({ + role: "tool", + tool_call_id: "read-1", + content: "Synthetic file contents.", + }), + ]), + ); + expect(result).toMatchObject({ + method: "llm_checkpoint", + summary, + generationAttempts: 1, + }); + expect(result.replacementMessages[0]).toMatchObject({ + role: "compactionSummary", + }); + }, + ); + + it.each([ + { label: "absent", tools: undefined }, + { label: "empty", tools: [] }, + ])("omits $label tool definitions with tool history", async ({ tools }) => { + const result = await streamSimple( + model, + { messages: history(model), tools }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + expect(result.stopReason).toBe("stop"); + }); + + it.each(["explicit", "detected"])( + "preserves the %s Anthropic proxy workaround even with caching disabled", + async (mode) => { + allowEmptyTools = true; + model = + mode === "explicit" + ? { ...model, compat: { cacheControlFormat: "anthropic" } } + : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; + const result = await streamSimple( + model, + { messages: history(model) }, + { + apiKey: "synthetic-key", + cacheRetention: "none", + }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests[0].tools).toEqual([]); + + await streamSimple( + model, + { + messages: [{ role: "user", content: "Hello", timestamp: 1 }], + tools: [], + }, + { + apiKey: "synthetic-key", + cacheRetention: "none", + }, + ).result(); + expect(requests[1]).not.toHaveProperty("tools"); + }, + ); + + it("preserves nonempty tool definitions on ordinary agent requests", async () => { + const result = await streamSimple( + model, + { + messages: history(model), + tools: [ + { + name: "read", + description: "Read a synthetic file", + parameters: Type.Object({ path: Type.String() }), + }, + ], + }, + { apiKey: "synthetic-key" }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests[0].tools).toEqual([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "read" }), + }), + ]); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 66e9d324e..a6dcf1c2d 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1443,6 +1443,7 @@ "packages/local-runtime-v2/src/services.ts", "packages/local-runtime-v2/test/helpers/agent-service.ts", "packages/local-runtime-v2/test/helpers/plugin-database.ts", + "packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/local-mcp-public-facade.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/project-mcp.integration.test.ts", "packages/local-runtime-v2/test/integration/session-system-queue-repository.integration.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 41d49c61f..5370791b8 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -2,6 +2,7 @@ "comment": "Vitest suites for the standalone distribution, grouped by the gate that runs them. `vitest.oss.config.mjs` includes every group; `scripts/run-vitest-suite.mjs ` runs one. Add a test file here rather than in package.json or the Vitest config.", "suites": { "capability": [ + "packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts", "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", "packages/agent-tools/src/shared/replace-all-edit.test.ts", diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index 02ca14e7b..f1ddc951d 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,6 +13,14 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. +### 2026-09-19 — omit empty tools for ordinary OpenAI-compatible checkpoint requests + +- Reason: checkpoint requests retain tool-call history but omit tool definitions. The OpenAI Completions provider unconditionally added `tools: []` for that history, which can cause a backend to reject compaction with HTTP 400 (public issue MiniMax-AI/minimax-code#194). +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts` and its existing empty-tools regression fixture. +- Change type: generic, upstreamable compatibility fix. Scope the existing Anthropic proxy workaround to resolved `cacheControlFormat: "anthropic"`, including explicit custom-provider overrides and detected OpenRouter Anthropic models. The workaround remains independent of cache retention; ordinary models omit empty tools while nonempty tool definitions are preserved. Custom Anthropic proxies that need the workaround must declare that compatibility mode. +- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against a local HTTP fixture. It also covers absent/empty definitions, explicit/detected Anthropic compatibility with caching disabled, and ordinary requests with tools. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. +- Upstream PR: not created. + ### 2026-08-31 — Windows PowerShell ConstrainedLanguage compatibility - Reason: the Windows PowerShell 5.1 stdin wrapper called `Parser.ParseInput`, `ScriptBlock.Create`, and other restricted .NET APIs before user commands. Under enterprise App Control / AppLocker `ConstrainedLanguage`, the wrapper therefore failed before commands such as Python could run. diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts index 20f3b9711..0a5ed9fb7 100644 --- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts +++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts @@ -545,8 +545,9 @@ function buildParams( if (compat.zaiToolStream) { (params as any).tool_stream = true; } - } else if (hasToolHistory(context.messages)) { - // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results + } else if (compat.cacheControlFormat === "anthropic" && hasToolHistory(context.messages)) { + // Keep the Anthropic proxy workaround scoped to Anthropic-compatible models. + // Other OpenAI-compatible backends may reject an empty tools array. params.tools = []; } diff --git a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts index a743351c0..46bd0b5dd 100644 --- a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts @@ -202,7 +202,7 @@ describe("openai-completions empty tools handling", () => { it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; - const model = { ...baseModel, api: "openai-completions" } as const; + const model = { ...baseModel, api: "openai-completions", compat: { cacheControlFormat: "anthropic" } } as const; await streamSimple( model, From 9099391692d3125f6ea56a671580b501e4f3301c Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sat, 19 Sep 2026 21:35:48 +0800 Subject: [PATCH 2/3] fix: negotiate missing tools with bounded compatibility recovery --- docs/examples.md | 18 ++ .../src/service/model-system/contracts.ts | 1 + .../resolution/local-model-resolver.test.ts | 5 + .../resolution/model-resolver-byok.test.ts | 9 + .../resolution/model-resolver-byok.ts | 1 + ...ction-openai-transport.integration.test.ts | 293 +++++++++++++++++- .../openai-chat-tokenizers.ts | 1 + third_party/pi-mono/MINIMAX_CHANGES.md | 6 +- .../ai/src/providers/openai-completions.ts | 63 +++- third_party/pi-mono/packages/ai/src/types.ts | 2 + .../openai-completions-empty-tools.test.ts | 2 +- 11 files changed, 374 insertions(+), 27 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index c162993dd..9cc4f111d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -68,6 +68,24 @@ pnpm mcode provider list --json [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. +### OpenAI-compatible gateways and tool history + +Checkpoint requests retain historical tool calls/results without offering new tools. Some gateways reject `tools: []`, while others require it for that history. With no override, MCode omits the field and retries with an empty array once only after a recognized missing-tools HTTP 400. Recognition is limited to a structured missing-required-parameter/argument code for `tools` or the known LiteLLM Anthropic missing-tools error. Other errors and failures after a response stream begins do not trigger this recovery. + +For a gateway with a known requirement, set `compat.requiresToolsForToolHistory` on the existing model entry in your active profile's `config.yaml`: + +```yaml +custom_provider: + my-provider: + # Preserve this provider's existing API, options, and other model settings. + models: + my-model: + compat: + requiresToolsForToolHistory: true +``` + +`true` sends empty tools proactively when history contains tools but no current definitions are supplied. `false` omits the field and disables automatic recovery in that case. Leaving the field unset enables the bounded recovery above. Nonempty tool definitions are preserved in all modes. This setting is independent of `cacheControlFormat`; prompt-cache support does not determine the tools requirement. These behaviors are covered by offline HTTP fixtures, not a guarantee for every live gateway/version. + ## 3. Search and image input After signing in to MiniMax, try a task that explicitly requires search: diff --git a/packages/local-runtime-v2/src/service/model-system/contracts.ts b/packages/local-runtime-v2/src/service/model-system/contracts.ts index b9a56084b..7aeb6d6ad 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -35,6 +35,7 @@ export interface LocalModelCompatOverrides { supportsReasoningEffort?: boolean; supportsUsageInStreaming?: boolean; requiresToolResultName?: boolean; + requiresToolsForToolHistory?: boolean; requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 1187b5710..870beb0d7 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1383,6 +1383,11 @@ describe('LocalModelResolver custom provider compat overrides', () => { expect(resolved.model.compat).toBeUndefined(); }); + it.each([true, false])('forwards requiresToolsForToolHistory=%s to the provider model', async (value) => { + const resolved = await resolveWithCompat({ requiresToolsForToolHistory: value }); + expect(resolved.model.compat).toMatchObject({ requiresToolsForToolHistory: value }); + }); + // The incident was a wire-level symptom: pi chooses the system prompt role from the // resolved compat, so these two cases pin the request pi would actually send. const reasoningModelConfig: LocalModelConfig = { diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts index 38cb6b7f3..1a22c8c6f 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts @@ -281,6 +281,15 @@ describe('custom BYOK compat overrides', () => { }); }); + it.each([true, false])('preserves requiresToolsForToolHistory=%s independently of caching', (value) => { + expect(planWithCompat(JSON.stringify({ compat: { requiresToolsForToolHistory: value } }))) + .toEqual({ requiresToolsForToolHistory: value }); + }); + + it('rejects a string-valued tools-history compatibility flag', () => { + expect(planWithCompat('{"compat":{"requiresToolsForToolHistory":"false"}}')).toBeUndefined(); + }); + it('drops a boolean field carrying a truthy string instead of a boolean', () => { expect(planWithCompat('{"compat":{"supportsDeveloperRole":"false"}}')).toBeUndefined(); }); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts index 3da72fb2f..710a310ae 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts @@ -183,6 +183,7 @@ const BOOLEAN_COMPAT_KEYS = [ 'supportsReasoningEffort', 'supportsUsageInStreaming', 'requiresToolResultName', + 'requiresToolsForToolHistory', 'requiresAssistantAfterToolResult', 'requiresThinkingAsText', 'requiresReasoningContentOnAssistantMessages', diff --git a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts index 073c5d16d..adbec87c3 100644 --- a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts +++ b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts @@ -3,13 +3,21 @@ import type { AddressInfo } from "node:net"; import { streamSimple, type Context, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { compactContext } from "../../src/service/turn-system/compaction/algorithm/compact-context.js"; import { createCheckpointSession } from "../../src/service/turn-system/compaction/execution/checkpoint-provider.js"; const summary = "The synthetic file was read. Continue with the pending user request."; +const missingTools = + "litellm.UnsupportedParamsError: Anthropic doesn't support tool calling without `tools=` param specified"; +interface Rejection { + status: number; + message: string; + param?: string; + code?: string; +} function history(model: Model<"openai-completions">): Context["messages"] { return [ @@ -54,10 +62,14 @@ describe("OpenAI compaction HTTP transport", () => { let model: Model<"openai-completions">; let requests: Record[]; let allowEmptyTools: boolean; + let rejectRequest: (body: Record) => Rejection | undefined; + let incompleteStream: boolean; beforeEach(async () => { requests = []; allowEmptyTools = false; + rejectRequest = () => undefined; + incompleteStream = false; // Exercise the real SDK serializer and stream parser with a local fixture. // This models the reported validation rule, not acceptance by a live service. server = createServer(async (request, response) => { @@ -65,6 +77,14 @@ describe("OpenAI compaction HTTP transport", () => { for await (const chunk of request) raw += chunk; const body = JSON.parse(raw); requests.push(body); + const rejection = rejectRequest(body); + if (rejection) { + const { status, ...error } = rejection; + response + .writeHead(status, { "content-type": "application/json" }) + .end(JSON.stringify({ error })); + return; + } if ( !allowEmptyTools && Array.isArray(body.tools) && @@ -92,6 +112,10 @@ describe("OpenAI compaction HTTP transport", () => { ], })}\n\n`, ); + if (incompleteStream) { + response.end(); + return; + } response.end( `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], @@ -129,10 +153,21 @@ describe("OpenAI compaction HTTP transport", () => { ); }); - it.each(["custom", "openai"])( - "compacts tool history through the %s provider without empty tools", - async (provider) => { + it.each([ + { provider: "custom", requiresTools: false }, + { provider: "openai", requiresTools: false }, + { provider: "custom", requiresTools: true }, + ])( + "compacts tool history through $provider (backend requires tools: $requiresTools)", + async ({ provider, requiresTools }) => { model = { ...model, provider }; + if (requiresTools) { + allowEmptyTools = true; + rejectRequest = (body) => + body.tools === undefined + ? { status: 400, message: missingTools } + : undefined; + } const result = await compactContext({ history: history(model), allowLegacyToolTrim: false, @@ -159,8 +194,10 @@ describe("OpenAI compaction HTTP transport", () => { }), }, }); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(requiresTools ? 2 : 1); expect(requests[0]).not.toHaveProperty("tools"); + if (requiresTools) + expect(requests[1]).toEqual({ ...requests[0], tools: [] }); expect(requests[0]).not.toHaveProperty("tool_choice"); expect(requests[0].messages).toEqual( expect.arrayContaining([ @@ -200,23 +237,26 @@ describe("OpenAI compaction HTTP transport", () => { expect(result.stopReason).toBe("stop"); }); - it.each(["explicit", "detected"])( - "preserves the %s Anthropic proxy workaround even with caching disabled", - async (mode) => { + it.each(["short", "none"] as const)( + "honors the independent tools requirement with cache retention %s", + async (cacheRetention) => { allowEmptyTools = true; - model = - mode === "explicit" - ? { ...model, compat: { cacheControlFormat: "anthropic" } } - : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; + model = { ...model, compat: { requiresToolsForToolHistory: true } }; + rejectRequest = (body) => + body.tools === undefined && + JSON.stringify(body.messages).includes("tool_calls") + ? { status: 400, message: missingTools } + : undefined; const result = await streamSimple( model, { messages: history(model) }, { apiKey: "synthetic-key", - cacheRetention: "none", + cacheRetention, }, ).result(); expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); expect(requests[0].tools).toEqual([]); await streamSimple( @@ -227,13 +267,238 @@ describe("OpenAI compaction HTTP transport", () => { }, { apiKey: "synthetic-key", - cacheRetention: "none", + cacheRetention, }, ).result(); expect(requests[1]).not.toHaveProperty("tools"); }, ); + it.each(["explicit", "detected"])( + "does not infer a tools requirement from %s Anthropic caching", + async (mode) => { + model = + mode === "explicit" + ? { ...model, compat: { cacheControlFormat: "anthropic" } } + : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key" }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(1); + expect(requests[0]).not.toHaveProperty("tools"); + }, + ); + + it.each(["missing_required_parameter", "missing_required_argument"])( + "recovers once from structured %s for tools", + async (code) => { + allowEmptyTools = true; + rejectRequest = (body) => + body.tools === undefined + ? { + status: 400, + message: "Required field missing", + param: "tools", + code, + } + : undefined; + const onPayload = vi.fn((payload: unknown) => ({ + ...(payload as Record), + user: "synthetic-user", + })); + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key", onPayload }, + ).result(); + expect(result.stopReason).toBe("stop"); + expect(requests).toHaveLength(2); + expect(onPayload).toHaveBeenCalledTimes(2); + expect(requests[1]).toEqual({ ...requests[0], tools: [] }); + }, + ); + + it("does not retry again if the recovery request also fails", async () => { + rejectRequest = () => ({ status: 400, message: missingTools }); + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(2); + expect(requests[1].tools).toEqual([]); + expect(result.stopReason).toBe("error"); + }); + + it("disables SDK retries on the recovery request", async () => { + rejectRequest = (body) => + body.tools === undefined + ? { status: 400, message: missingTools } + : { status: 500, message: "Synthetic server failure" }; + const result = await streamSimple( + model, + { messages: history(model) }, + { + apiKey: "synthetic-key", + maxRetries: 3, + }, + ).result(); + expect(requests).toHaveLength(2); + expect(result.stopReason).toBe("error"); + }); + + it.each([ + "no history", + "nonempty definitions", + "explicit true", + "hook tools", + ])("does not recover with %s", async (mode) => { + rejectRequest = () => ({ status: 400, message: missingTools }); + if (mode === "explicit true") + model = { ...model, compat: { requiresToolsForToolHistory: true } }; + const context: Context = { + messages: + mode === "no history" + ? [{ role: "user", content: "Hello", timestamp: 1 }] + : history(model), + }; + if (mode === "nonempty definitions") + context.tools = [ + { + name: "read", + description: "Read a file", + parameters: Type.Object({}), + }, + ]; + const result = await streamSimple(model, context, { + apiKey: "synthetic-key", + onPayload: + mode === "hook tools" + ? (payload) => ({ + ...(payload as Record), + tools: [], + }) + : undefined, + }).result(); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("error"); + }); + + it.each([ + { + status: 400, + message: "tools must not be an empty array", + param: "tools", + }, + { status: 400, message: "invalid tool schema", param: "tools" }, + { status: 400, message: `Invalid user content: ${missingTools}` }, + { + status: 400, + message: "Required field missing", + param: "messages", + code: "missing_required_parameter", + }, + ...[401, 403, 429, 500].map((status) => ({ + status, + message: missingTools, + })), + ])( + "does not recover unrelated error $status: $message ($param)", + async (rejection) => { + rejectRequest = () => rejection; + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("error"); + }, + ); + + it("honors explicit false even when the backend requires tools", async () => { + model = { ...model, compat: { requiresToolsForToolHistory: false } }; + rejectRequest = () => ({ status: 400, message: missingTools }); + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("error"); + }); + + it("does not recover without tool history in the final payload", async () => { + rejectRequest = () => ({ status: 400, message: missingTools }); + const result = await streamSimple( + model, + { messages: history(model) }, + { + apiKey: "synthetic-key", + onPayload: (payload) => ({ + ...(payload as Record), + messages: [{ role: "user", content: "Hello" }], + }), + }, + ).result(); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("error"); + }); + + it("does not resend when a payload hook removes the recovery tools", async () => { + rejectRequest = () => ({ status: 400, message: missingTools }); + const onPayload = vi.fn((payload: unknown) => { + const { tools: _tools, ...rest } = payload as Record; + return rest; + }); + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key", onPayload }, + ).result(); + expect(onPayload).toHaveBeenCalledTimes(2); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("error"); + }); + + it("honors cancellation during the recovery payload hook", async () => { + rejectRequest = () => ({ status: 400, message: missingTools }); + const controller = new AbortController(); + const onPayload = vi.fn((payload: unknown) => { + if (Array.isArray((payload as Record).tools)) + controller.abort(); + }); + const result = await streamSimple( + model, + { messages: history(model) }, + { + apiKey: "synthetic-key", + signal: controller.signal, + onPayload, + }, + ).result(); + expect(onPayload).toHaveBeenCalledTimes(2); + expect(requests).toHaveLength(1); + expect(result.stopReason).toBe("aborted"); + }); + + it("does not restart a response stream that ended without a finish reason", async () => { + incompleteStream = true; + const result = await streamSimple( + model, + { messages: history(model) }, + { apiKey: "synthetic-key" }, + ).result(); + expect(requests).toHaveLength(1); + expect(result.content).toEqual([ + expect.objectContaining({ type: "text", text: summary }), + ]); + expect(result.stopReason).toBe("error"); + }); + it("preserves nonempty tool definitions on ordinary agent requests", async () => { const result = await streamSimple( model, diff --git a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts index 496230d3e..da31b15cf 100644 --- a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts +++ b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts @@ -30,6 +30,7 @@ const BASE_CHAT_COMPAT: OpenAICompletionsCompat = { supportsUsageInStreaming: true, maxTokensField: 'max_tokens', requiresToolResultName: false, + requiresToolsForToolHistory: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index f1ddc951d..303ff514d 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -16,9 +16,9 @@ No upstream source files are changed in the baseline import. ### 2026-09-19 — omit empty tools for ordinary OpenAI-compatible checkpoint requests - Reason: checkpoint requests retain tool-call history but omit tool definitions. The OpenAI Completions provider unconditionally added `tools: []` for that history, which can cause a backend to reject compaction with HTTP 400 (public issue MiniMax-AI/minimax-code#194). -- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts` and its existing empty-tools regression fixture. -- Change type: generic, upstreamable compatibility fix. Scope the existing Anthropic proxy workaround to resolved `cacheControlFormat: "anthropic"`, including explicit custom-provider overrides and detected OpenRouter Anthropic models. The workaround remains independent of cache retention; ordinary models omit empty tools while nonempty tool definitions are preserved. Custom Anthropic proxies that need the workaround must declare that compatibility mode. -- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against a local HTTP fixture. It also covers absent/empty definitions, explicit/detected Anthropic compatibility with caching disabled, and ordinary requests with tools. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts`, `src/types.ts`, and its existing empty-tools regression fixture. The distribution's BYOK model resolver forwards the new compatibility field. +- Change type: generic, upstreamable compatibility fix. `compat.requiresToolsForToolHistory` is independent of caching and provider/model naming: `true` proactively sends empty tools for tool history, `false` suppresses the field and automatic recovery when definitions are absent, and unset omits initially but permits one recovery request after a recognized missing-tools HTTP 400. Recognition requires either a structured missing-required-parameter/argument code for `tools` or the known LiteLLM Anthropic missing-tools message (upstream issue earendil-works/pi#149). Nonempty tools are unchanged. Recovery reruns payload hooks, honors cancellation, disables SDK retries for the recovery request, and never restarts a response stream or handles unrelated failures. No endpoint-wide capability cache is introduced. +- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against local HTTP fixtures that reject empty tools or require tools. Regressions also cover independent compatibility overrides, BYOK resolution, payload hooks, cancellation, terminal errors, and bounded recovery. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. - Upstream PR: not created. ### 2026-08-31 — Windows PowerShell ConstrainedLanguage compatibility diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts index 0a5ed9fb7..3b6194c8a 100644 --- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts +++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts @@ -59,6 +59,24 @@ function hasToolHistory(messages: Message[]): boolean { return false; } +/** Recognize missing tools, never generic tool-validation or empty-array errors. */ +function isMissingToolsError(error: unknown): boolean { + if (!error || typeof error !== "object" || Reflect.get(error, "status") !== 400) return false; + const detail = Reflect.get(error, "error"); + if (!detail || typeof detail !== "object") return false; + if ( + Reflect.get(detail, "param") === "tools" && + ["missing_required_parameter", "missing_required_argument"].includes(Reflect.get(detail, "code")) + ) { + return true; + } + const message = Reflect.get(detail, "message"); + return ( + typeof message === "string" && + /^(?:litellm\.UnsupportedParamsError:\s*)?Anthropic doesn't support tool calling without `?tools=`? param specified\b/i.test(message) + ); +} + function isTextContentBlock(block: { type: string }): block is TextContent { return block.type === "text"; } @@ -144,11 +162,14 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.fetch); - let params = buildParams(model, context, options, compat, cacheRetention); - const nextParams = await options?.onPayload?.(params, model); - if (nextParams !== undefined) { - params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; - } + const prepareParams = async (includeEmptyTools = false) => { + const params = buildParams(model, context, options, compat, cacheRetention); + if (includeEmptyTools) params.tools = []; + const nextParams = await options?.onPayload?.(params, model); + options?.signal?.throwIfAborted(); + return nextParams === undefined ? params : (nextParams as typeof params); + }; + const params = await prepareParams(); const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), @@ -156,7 +177,29 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }; const { data: openaiStream, response } = await client.chat.completions .create(params, requestOptions) - .withResponse(); + .withResponse() + .catch(async (error: unknown) => { + // Recover only before any response stream starts. Explicit false, real + // tool definitions, and payload transforms that remove history take precedence. + if ( + model.compat?.requiresToolsForToolHistory === false || + context.tools?.length || + params.tools !== undefined || + !params.messages.some( + (message) => message.role === "tool" || (message.role === "assistant" && message.tool_calls?.length), + ) || + !isMissingToolsError(error) + ) { + throw error; + } + options?.signal?.throwIfAborted(); + const retryParams = await prepareParams(true); + if (!Array.isArray(retryParams.tools) || retryParams.tools.length !== 0) throw error; + // No loop and no SDK retries: at most one compatibility recovery request. + return client.chat.completions + .create(retryParams, { ...requestOptions, maxRetries: 0 }) + .withResponse(); + }); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); @@ -545,9 +588,9 @@ function buildParams( if (compat.zaiToolStream) { (params as any).tool_stream = true; } - } else if (compat.cacheControlFormat === "anthropic" && hasToolHistory(context.messages)) { - // Keep the Anthropic proxy workaround scoped to Anthropic-compatible models. - // Other OpenAI-compatible backends may reject an empty tools array. + } else if (compat.requiresToolsForToolHistory && hasToolHistory(context.messages)) { + // Some proxies require this field; others reject empty arrays. Do not infer + // the requirement from prompt-cache support or a model/provider name. params.tools = []; } @@ -1131,6 +1174,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, + requiresToolsForToolHistory: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: isDeepSeek, @@ -1176,6 +1220,7 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, + requiresToolsForToolHistory: model.compat.requiresToolsForToolHistory ?? detected.requiresToolsForToolHistory, requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, diff --git a/third_party/pi-mono/packages/ai/src/types.ts b/third_party/pi-mono/packages/ai/src/types.ts index 96ac519fb..a39816ba0 100644 --- a/third_party/pi-mono/packages/ai/src/types.ts +++ b/third_party/pi-mono/packages/ai/src/types.ts @@ -411,6 +411,8 @@ export interface OpenAICompletionsCompat { maxTokensField?: "max_completion_tokens" | "max_tokens"; /** Whether tool results require the `name` field. Default: auto-detected from URL. */ requiresToolResultName?: boolean; + /** Send `tools: []` for tool history without current definitions. True: always; false: never (including automatic recovery). Unset: omit initially, retry once only on a recognized missing-tools HTTP 400. Independent of prompt caching. */ + requiresToolsForToolHistory?: boolean; /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ requiresAssistantAfterToolResult?: boolean; /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ diff --git a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts index 46bd0b5dd..8a6e90c7d 100644 --- a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts @@ -202,7 +202,7 @@ describe("openai-completions empty tools handling", () => { it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; - const model = { ...baseModel, api: "openai-completions", compat: { cacheControlFormat: "anthropic" } } as const; + const model = { ...baseModel, api: "openai-completions", compat: { requiresToolsForToolHistory: true } } as const; await streamSimple( model, From 237f0504324034dbb9be3447bc654f166967c7ae Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sat, 19 Sep 2026 21:48:12 +0800 Subject: [PATCH 3/3] fix: simplify empty tools handling to omit absent definitions --- docs/examples.md | 18 -- .../src/service/model-system/contracts.ts | 1 - .../resolution/local-model-resolver.test.ts | 5 - .../resolution/model-resolver-byok.test.ts | 9 - .../resolution/model-resolver-byok.ts | 1 - ...ction-openai-transport.integration.test.ts | 303 +----------------- .../openai-chat-tokenizers.ts | 1 - third_party/pi-mono/MINIMAX_CHANGES.md | 8 +- .../ai/src/providers/openai-completions.ts | 80 +---- third_party/pi-mono/packages/ai/src/types.ts | 2 - .../openai-completions-empty-tools.test.ts | 7 +- 11 files changed, 29 insertions(+), 406 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 9cc4f111d..c162993dd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -68,24 +68,6 @@ pnpm mcode provider list --json [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. -### OpenAI-compatible gateways and tool history - -Checkpoint requests retain historical tool calls/results without offering new tools. Some gateways reject `tools: []`, while others require it for that history. With no override, MCode omits the field and retries with an empty array once only after a recognized missing-tools HTTP 400. Recognition is limited to a structured missing-required-parameter/argument code for `tools` or the known LiteLLM Anthropic missing-tools error. Other errors and failures after a response stream begins do not trigger this recovery. - -For a gateway with a known requirement, set `compat.requiresToolsForToolHistory` on the existing model entry in your active profile's `config.yaml`: - -```yaml -custom_provider: - my-provider: - # Preserve this provider's existing API, options, and other model settings. - models: - my-model: - compat: - requiresToolsForToolHistory: true -``` - -`true` sends empty tools proactively when history contains tools but no current definitions are supplied. `false` omits the field and disables automatic recovery in that case. Leaving the field unset enables the bounded recovery above. Nonempty tool definitions are preserved in all modes. This setting is independent of `cacheControlFormat`; prompt-cache support does not determine the tools requirement. These behaviors are covered by offline HTTP fixtures, not a guarantee for every live gateway/version. - ## 3. Search and image input After signing in to MiniMax, try a task that explicitly requires search: diff --git a/packages/local-runtime-v2/src/service/model-system/contracts.ts b/packages/local-runtime-v2/src/service/model-system/contracts.ts index 7aeb6d6ad..b9a56084b 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -35,7 +35,6 @@ export interface LocalModelCompatOverrides { supportsReasoningEffort?: boolean; supportsUsageInStreaming?: boolean; requiresToolResultName?: boolean; - requiresToolsForToolHistory?: boolean; requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; requiresReasoningContentOnAssistantMessages?: boolean; diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index 870beb0d7..1187b5710 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1383,11 +1383,6 @@ describe('LocalModelResolver custom provider compat overrides', () => { expect(resolved.model.compat).toBeUndefined(); }); - it.each([true, false])('forwards requiresToolsForToolHistory=%s to the provider model', async (value) => { - const resolved = await resolveWithCompat({ requiresToolsForToolHistory: value }); - expect(resolved.model.compat).toMatchObject({ requiresToolsForToolHistory: value }); - }); - // The incident was a wire-level symptom: pi chooses the system prompt role from the // resolved compat, so these two cases pin the request pi would actually send. const reasoningModelConfig: LocalModelConfig = { diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts index 1a22c8c6f..38cb6b7f3 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts @@ -281,15 +281,6 @@ describe('custom BYOK compat overrides', () => { }); }); - it.each([true, false])('preserves requiresToolsForToolHistory=%s independently of caching', (value) => { - expect(planWithCompat(JSON.stringify({ compat: { requiresToolsForToolHistory: value } }))) - .toEqual({ requiresToolsForToolHistory: value }); - }); - - it('rejects a string-valued tools-history compatibility flag', () => { - expect(planWithCompat('{"compat":{"requiresToolsForToolHistory":"false"}}')).toBeUndefined(); - }); - it('drops a boolean field carrying a truthy string instead of a boolean', () => { expect(planWithCompat('{"compat":{"supportsDeveloperRole":"false"}}')).toBeUndefined(); }); diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts index 710a310ae..3da72fb2f 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts @@ -183,7 +183,6 @@ const BOOLEAN_COMPAT_KEYS = [ 'supportsReasoningEffort', 'supportsUsageInStreaming', 'requiresToolResultName', - 'requiresToolsForToolHistory', 'requiresAssistantAfterToolResult', 'requiresThinkingAsText', 'requiresReasoningContentOnAssistantMessages', diff --git a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts index adbec87c3..14d052204 100644 --- a/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts +++ b/packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts @@ -3,21 +3,13 @@ import type { AddressInfo } from "node:net"; import { streamSimple, type Context, type Model } from "@earendil-works/pi-ai"; import { Type } from "typebox"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { compactContext } from "../../src/service/turn-system/compaction/algorithm/compact-context.js"; import { createCheckpointSession } from "../../src/service/turn-system/compaction/execution/checkpoint-provider.js"; const summary = "The synthetic file was read. Continue with the pending user request."; -const missingTools = - "litellm.UnsupportedParamsError: Anthropic doesn't support tool calling without `tools=` param specified"; -interface Rejection { - status: number; - message: string; - param?: string; - code?: string; -} function history(model: Model<"openai-completions">): Context["messages"] { return [ @@ -61,15 +53,9 @@ describe("OpenAI compaction HTTP transport", () => { let server: ReturnType; let model: Model<"openai-completions">; let requests: Record[]; - let allowEmptyTools: boolean; - let rejectRequest: (body: Record) => Rejection | undefined; - let incompleteStream: boolean; beforeEach(async () => { requests = []; - allowEmptyTools = false; - rejectRequest = () => undefined; - incompleteStream = false; // Exercise the real SDK serializer and stream parser with a local fixture. // This models the reported validation rule, not acceptance by a live service. server = createServer(async (request, response) => { @@ -77,19 +63,7 @@ describe("OpenAI compaction HTTP transport", () => { for await (const chunk of request) raw += chunk; const body = JSON.parse(raw); requests.push(body); - const rejection = rejectRequest(body); - if (rejection) { - const { status, ...error } = rejection; - response - .writeHead(status, { "content-type": "application/json" }) - .end(JSON.stringify({ error })); - return; - } - if ( - !allowEmptyTools && - Array.isArray(body.tools) && - body.tools.length === 0 - ) { + if (Array.isArray(body.tools) && body.tools.length === 0) { response.writeHead(400, { "content-type": "application/json" }).end( JSON.stringify({ error: { @@ -112,10 +86,6 @@ describe("OpenAI compaction HTTP transport", () => { ], })}\n\n`, ); - if (incompleteStream) { - response.end(); - return; - } response.end( `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], @@ -153,21 +123,10 @@ describe("OpenAI compaction HTTP transport", () => { ); }); - it.each([ - { provider: "custom", requiresTools: false }, - { provider: "openai", requiresTools: false }, - { provider: "custom", requiresTools: true }, - ])( - "compacts tool history through $provider (backend requires tools: $requiresTools)", - async ({ provider, requiresTools }) => { + it.each(["custom", "openai"])( + "compacts tool history through the %s provider without empty tools", + async (provider) => { model = { ...model, provider }; - if (requiresTools) { - allowEmptyTools = true; - rejectRequest = (body) => - body.tools === undefined - ? { status: 400, message: missingTools } - : undefined; - } const result = await compactContext({ history: history(model), allowLegacyToolTrim: false, @@ -194,10 +153,8 @@ describe("OpenAI compaction HTTP transport", () => { }), }, }); - expect(requests).toHaveLength(requiresTools ? 2 : 1); + expect(requests).toHaveLength(1); expect(requests[0]).not.toHaveProperty("tools"); - if (requiresTools) - expect(requests[1]).toEqual({ ...requests[0], tools: [] }); expect(requests[0]).not.toHaveProperty("tool_choice"); expect(requests[0].messages).toEqual( expect.arrayContaining([ @@ -237,27 +194,24 @@ describe("OpenAI compaction HTTP transport", () => { expect(result.stopReason).toBe("stop"); }); - it.each(["short", "none"] as const)( - "honors the independent tools requirement with cache retention %s", - async (cacheRetention) => { - allowEmptyTools = true; - model = { ...model, compat: { requiresToolsForToolHistory: true } }; - rejectRequest = (body) => - body.tools === undefined && - JSON.stringify(body.messages).includes("tool_calls") - ? { status: 400, message: missingTools } - : undefined; + it.each(["explicit", "detected"])( + "omits tools with %s Anthropic cache compatibility and tool history", + async (mode) => { + model = + mode === "explicit" + ? { ...model, compat: { cacheControlFormat: "anthropic" } } + : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; const result = await streamSimple( model, { messages: history(model) }, { apiKey: "synthetic-key", - cacheRetention, + cacheRetention: "none", }, ).result(); expect(result.stopReason).toBe("stop"); expect(requests).toHaveLength(1); - expect(requests[0].tools).toEqual([]); + expect(requests[0]).not.toHaveProperty("tools"); await streamSimple( model, @@ -267,238 +221,13 @@ describe("OpenAI compaction HTTP transport", () => { }, { apiKey: "synthetic-key", - cacheRetention, + cacheRetention: "none", }, ).result(); expect(requests[1]).not.toHaveProperty("tools"); }, ); - it.each(["explicit", "detected"])( - "does not infer a tools requirement from %s Anthropic caching", - async (mode) => { - model = - mode === "explicit" - ? { ...model, compat: { cacheControlFormat: "anthropic" } } - : { ...model, provider: "openrouter", id: "anthropic/fixture-model" }; - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key" }, - ).result(); - expect(result.stopReason).toBe("stop"); - expect(requests).toHaveLength(1); - expect(requests[0]).not.toHaveProperty("tools"); - }, - ); - - it.each(["missing_required_parameter", "missing_required_argument"])( - "recovers once from structured %s for tools", - async (code) => { - allowEmptyTools = true; - rejectRequest = (body) => - body.tools === undefined - ? { - status: 400, - message: "Required field missing", - param: "tools", - code, - } - : undefined; - const onPayload = vi.fn((payload: unknown) => ({ - ...(payload as Record), - user: "synthetic-user", - })); - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key", onPayload }, - ).result(); - expect(result.stopReason).toBe("stop"); - expect(requests).toHaveLength(2); - expect(onPayload).toHaveBeenCalledTimes(2); - expect(requests[1]).toEqual({ ...requests[0], tools: [] }); - }, - ); - - it("does not retry again if the recovery request also fails", async () => { - rejectRequest = () => ({ status: 400, message: missingTools }); - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key" }, - ).result(); - expect(requests).toHaveLength(2); - expect(requests[1].tools).toEqual([]); - expect(result.stopReason).toBe("error"); - }); - - it("disables SDK retries on the recovery request", async () => { - rejectRequest = (body) => - body.tools === undefined - ? { status: 400, message: missingTools } - : { status: 500, message: "Synthetic server failure" }; - const result = await streamSimple( - model, - { messages: history(model) }, - { - apiKey: "synthetic-key", - maxRetries: 3, - }, - ).result(); - expect(requests).toHaveLength(2); - expect(result.stopReason).toBe("error"); - }); - - it.each([ - "no history", - "nonempty definitions", - "explicit true", - "hook tools", - ])("does not recover with %s", async (mode) => { - rejectRequest = () => ({ status: 400, message: missingTools }); - if (mode === "explicit true") - model = { ...model, compat: { requiresToolsForToolHistory: true } }; - const context: Context = { - messages: - mode === "no history" - ? [{ role: "user", content: "Hello", timestamp: 1 }] - : history(model), - }; - if (mode === "nonempty definitions") - context.tools = [ - { - name: "read", - description: "Read a file", - parameters: Type.Object({}), - }, - ]; - const result = await streamSimple(model, context, { - apiKey: "synthetic-key", - onPayload: - mode === "hook tools" - ? (payload) => ({ - ...(payload as Record), - tools: [], - }) - : undefined, - }).result(); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("error"); - }); - - it.each([ - { - status: 400, - message: "tools must not be an empty array", - param: "tools", - }, - { status: 400, message: "invalid tool schema", param: "tools" }, - { status: 400, message: `Invalid user content: ${missingTools}` }, - { - status: 400, - message: "Required field missing", - param: "messages", - code: "missing_required_parameter", - }, - ...[401, 403, 429, 500].map((status) => ({ - status, - message: missingTools, - })), - ])( - "does not recover unrelated error $status: $message ($param)", - async (rejection) => { - rejectRequest = () => rejection; - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key" }, - ).result(); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("error"); - }, - ); - - it("honors explicit false even when the backend requires tools", async () => { - model = { ...model, compat: { requiresToolsForToolHistory: false } }; - rejectRequest = () => ({ status: 400, message: missingTools }); - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key" }, - ).result(); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("error"); - }); - - it("does not recover without tool history in the final payload", async () => { - rejectRequest = () => ({ status: 400, message: missingTools }); - const result = await streamSimple( - model, - { messages: history(model) }, - { - apiKey: "synthetic-key", - onPayload: (payload) => ({ - ...(payload as Record), - messages: [{ role: "user", content: "Hello" }], - }), - }, - ).result(); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("error"); - }); - - it("does not resend when a payload hook removes the recovery tools", async () => { - rejectRequest = () => ({ status: 400, message: missingTools }); - const onPayload = vi.fn((payload: unknown) => { - const { tools: _tools, ...rest } = payload as Record; - return rest; - }); - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key", onPayload }, - ).result(); - expect(onPayload).toHaveBeenCalledTimes(2); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("error"); - }); - - it("honors cancellation during the recovery payload hook", async () => { - rejectRequest = () => ({ status: 400, message: missingTools }); - const controller = new AbortController(); - const onPayload = vi.fn((payload: unknown) => { - if (Array.isArray((payload as Record).tools)) - controller.abort(); - }); - const result = await streamSimple( - model, - { messages: history(model) }, - { - apiKey: "synthetic-key", - signal: controller.signal, - onPayload, - }, - ).result(); - expect(onPayload).toHaveBeenCalledTimes(2); - expect(requests).toHaveLength(1); - expect(result.stopReason).toBe("aborted"); - }); - - it("does not restart a response stream that ended without a finish reason", async () => { - incompleteStream = true; - const result = await streamSimple( - model, - { messages: history(model) }, - { apiKey: "synthetic-key" }, - ).result(); - expect(requests).toHaveLength(1); - expect(result.content).toEqual([ - expect.objectContaining({ type: "text", text: summary }), - ]); - expect(result.stopReason).toBe("error"); - }); - it("preserves nonempty tool definitions on ordinary agent requests", async () => { const result = await streamSimple( model, diff --git a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts index da31b15cf..496230d3e 100644 --- a/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts +++ b/packages/local-runtime/src/context/token-counter-adapters/openai-chat-tokenizers.ts @@ -30,7 +30,6 @@ const BASE_CHAT_COMPAT: OpenAICompletionsCompat = { supportsUsageInStreaming: true, maxTokensField: 'max_tokens', requiresToolResultName: false, - requiresToolsForToolHistory: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: false, diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index 303ff514d..0a47e745b 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,12 +13,12 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. -### 2026-09-19 — omit empty tools for ordinary OpenAI-compatible checkpoint requests +### 2026-09-19 — omit empty tools for OpenAI-compatible checkpoint requests - Reason: checkpoint requests retain tool-call history but omit tool definitions. The OpenAI Completions provider unconditionally added `tools: []` for that history, which can cause a backend to reject compaction with HTTP 400 (public issue MiniMax-AI/minimax-code#194). -- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts`, `src/types.ts`, and its existing empty-tools regression fixture. The distribution's BYOK model resolver forwards the new compatibility field. -- Change type: generic, upstreamable compatibility fix. `compat.requiresToolsForToolHistory` is independent of caching and provider/model naming: `true` proactively sends empty tools for tool history, `false` suppresses the field and automatic recovery when definitions are absent, and unset omits initially but permits one recovery request after a recognized missing-tools HTTP 400. Recognition requires either a structured missing-required-parameter/argument code for `tools` or the known LiteLLM Anthropic missing-tools message (upstream issue earendil-works/pi#149). Nonempty tools are unchanged. Recovery reruns payload hooks, honors cancellation, disables SDK retries for the recovery request, and never restarts a response stream or handles unrelated failures. No endpoint-wide capability cache is introduced. -- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against local HTTP fixtures that reject empty tools or require tools. Regressions also cover independent compatibility overrides, BYOK resolution, payload hooks, cancellation, terminal errors, and bounded recovery. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. +- Affected package: `packages/ai` (`@earendil-works/pi-ai`), `src/providers/openai-completions.ts` and its existing empty-tools regression fixture. +- Change type: generic, upstreamable compatibility fix. Omit `tools` when no nonempty tool definitions are supplied, regardless of tool-call history or cache compatibility. Nonempty tool definitions are unchanged. Remove the obsolete history-based empty-array workaround; no compatibility settings or recovery requests are added. +- Validation: the distribution-owned `packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` exercises `compactContext` through checkpoint generation and the real SDK against a local HTTP fixture that rejects empty tools. Regressions cover absent/empty definitions, tool history, Anthropic cache compatibility, ordinary requests, and preserved nonempty definitions. Run with `pnpm exec vitest run --config vitest.oss.config.mjs packages/local-runtime-v2/test/integration/compaction-openai-transport.integration.test.ts` and the full `pnpm verify` profile. Offline fixtures do not establish live LiteLLM/vLLM, OpenAI, or Anthropic proxy acceptance. - Upstream PR: not created. ### 2026-08-31 — Windows PowerShell ConstrainedLanguage compatibility diff --git a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts index 3b6194c8a..628dbbd70 100644 --- a/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts +++ b/third_party/pi-mono/packages/ai/src/providers/openai-completions.ts @@ -40,43 +40,6 @@ import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { buildBaseOptions } from "./simple-options.ts"; import { transformMessages } from "./transform-messages.ts"; -/** - * Check if conversation messages contain tool calls or tool results. - * This is needed because Anthropic (via proxy) requires the tools param - * to be present when messages include tool_calls or tool role messages. - */ -function hasToolHistory(messages: Message[]): boolean { - for (const msg of messages) { - if (msg.role === "toolResult") { - return true; - } - if (msg.role === "assistant") { - if (msg.content.some((block) => block.type === "toolCall")) { - return true; - } - } - } - return false; -} - -/** Recognize missing tools, never generic tool-validation or empty-array errors. */ -function isMissingToolsError(error: unknown): boolean { - if (!error || typeof error !== "object" || Reflect.get(error, "status") !== 400) return false; - const detail = Reflect.get(error, "error"); - if (!detail || typeof detail !== "object") return false; - if ( - Reflect.get(detail, "param") === "tools" && - ["missing_required_parameter", "missing_required_argument"].includes(Reflect.get(detail, "code")) - ) { - return true; - } - const message = Reflect.get(detail, "message"); - return ( - typeof message === "string" && - /^(?:litellm\.UnsupportedParamsError:\s*)?Anthropic doesn't support tool calling without `?tools=`? param specified\b/i.test(message) - ); -} - function isTextContentBlock(block: { type: string }): block is TextContent { return block.type === "text"; } @@ -162,14 +125,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat, options?.fetch); - const prepareParams = async (includeEmptyTools = false) => { - const params = buildParams(model, context, options, compat, cacheRetention); - if (includeEmptyTools) params.tools = []; - const nextParams = await options?.onPayload?.(params, model); - options?.signal?.throwIfAborted(); - return nextParams === undefined ? params : (nextParams as typeof params); - }; - const params = await prepareParams(); + let params = buildParams(model, context, options, compat, cacheRetention); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; + } const requestOptions = { ...(options?.signal ? { signal: options.signal } : {}), ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), @@ -177,29 +137,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }; const { data: openaiStream, response } = await client.chat.completions .create(params, requestOptions) - .withResponse() - .catch(async (error: unknown) => { - // Recover only before any response stream starts. Explicit false, real - // tool definitions, and payload transforms that remove history take precedence. - if ( - model.compat?.requiresToolsForToolHistory === false || - context.tools?.length || - params.tools !== undefined || - !params.messages.some( - (message) => message.role === "tool" || (message.role === "assistant" && message.tool_calls?.length), - ) || - !isMissingToolsError(error) - ) { - throw error; - } - options?.signal?.throwIfAborted(); - const retryParams = await prepareParams(true); - if (!Array.isArray(retryParams.tools) || retryParams.tools.length !== 0) throw error; - // No loop and no SDK retries: at most one compatibility recovery request. - return client.chat.completions - .create(retryParams, { ...requestOptions, maxRetries: 0 }) - .withResponse(); - }); + .withResponse(); await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); stream.push({ type: "start", partial: output }); @@ -588,10 +526,6 @@ function buildParams( if (compat.zaiToolStream) { (params as any).tool_stream = true; } - } else if (compat.requiresToolsForToolHistory && hasToolHistory(context.messages)) { - // Some proxies require this field; others reject empty arrays. Do not infer - // the requirement from prompt-cache support or a model/provider name. - params.tools = []; } if (cacheControl) { @@ -1174,7 +1108,6 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, - requiresToolsForToolHistory: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresReasoningContentOnAssistantMessages: isDeepSeek, @@ -1220,7 +1153,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, - requiresToolsForToolHistory: model.compat.requiresToolsForToolHistory ?? detected.requiresToolsForToolHistory, requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, diff --git a/third_party/pi-mono/packages/ai/src/types.ts b/third_party/pi-mono/packages/ai/src/types.ts index a39816ba0..96ac519fb 100644 --- a/third_party/pi-mono/packages/ai/src/types.ts +++ b/third_party/pi-mono/packages/ai/src/types.ts @@ -411,8 +411,6 @@ export interface OpenAICompletionsCompat { maxTokensField?: "max_completion_tokens" | "max_tokens"; /** Whether tool results require the `name` field. Default: auto-detected from URL. */ requiresToolResultName?: boolean; - /** Send `tools: []` for tool history without current definitions. True: always; false: never (including automatic recovery). Unset: omit initially, retry once only on a recognized missing-tools HTTP 400. Independent of prompt caching. */ - requiresToolsForToolHistory?: boolean; /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ requiresAssistantAfterToolResult?: boolean; /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ diff --git a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts index 8a6e90c7d..aff960a37 100644 --- a/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/third_party/pi-mono/packages/ai/test/openai-completions-empty-tools.test.ts @@ -200,9 +200,9 @@ describe("openai-completions empty tools handling", () => { expect(clientOptions.defaultHeaders?.["x-session-affinity"]).toBe("session-1"); }); - it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => { + it("omits tools when conversation has tool history but no tool definitions", async () => { const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; - const model = { ...baseModel, api: "openai-completions", compat: { requiresToolsForToolHistory: true } } as const; + const model = { ...baseModel, api: "openai-completions" } as const; await streamSimple( model, @@ -248,7 +248,6 @@ describe("openai-completions empty tools handling", () => { ).result(); const params = mockState.lastParams as { tools?: unknown[] }; - expect(Array.isArray(params.tools)).toBe(true); - expect(params.tools).toEqual([]); + expect(params).not.toHaveProperty("tools"); }); });