diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 511a04a6135..1eefc7e0300 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -478,12 +478,22 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: ] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const messages = [...system] + const requireAssistantAfterTool = + request.model.compatibility?.requireAssistantAfterTool ?? + ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => + request.model.id.toLowerCase().includes(family), + ) + const bridgeTools = () => { + if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." }) + } const pendingImages: Array> = [] const flushImages = () => { if (pendingImages.length === 0) return + bridgeTools() messages.push({ role: "user", content: pendingImages.splice(0) }) } for (const message of request.messages) { + if (message.role === "user") bridgeTools() if (message.role === "system") { const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message) if (pendingImages.length > 0) { diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 05342d6ebe5..e6aae721d84 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -155,6 +155,7 @@ export class LanguageModelCompatibility extends Schema.Class { }), ) + it.effect("bridges image tool results before their synthetic user message when required", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }), + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]), + Message.tool({ + id: "call_image", + name: "read", + result: { + type: "content", + value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }], + }, + }), + ], + }), + ) + + expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"]) + expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." }) + }), + ) + it.effect("orders parallel tool responses before one aggregated vision message", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index c4108d1b402..c2748ae0135 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -238,6 +238,47 @@ describe("OpenAI-compatible Chat route", () => { }), ) + it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () => + Effect.gen(function* () { + const cases = [ + { id: "mistral-small", bridge: true }, + { id: "devstral-small", bridge: true }, + { id: "codestral-latest", bridge: true }, + { id: "pixtral-large", bridge: true }, + { id: "open-mixtral-8x22b", bridge: true }, + { id: "ordinary-model", bridge: false }, + { id: "ordinary-model", override: true, bridge: true }, + { id: "mistral-small", override: false, bridge: false }, + ] as const + + yield* Effect.forEach(cases, (item) => + Effect.gen(function* () { + const selected = OpenAICompatibleChat.route + .with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } }) + .model({ + id: item.id, + compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined, + }) + const prepared = yield* compileRequest( + LLM.request({ + model: selected, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]), + Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }), + Message.user("What next?"), + ], + }), + ) + + expect(prepared.body.messages.map((message) => message.role)).toEqual( + item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"], + ) + if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." }) + }), + ) + }), + ) + it.effect("posts to the configured compatible endpoint and parses text usage", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 122631fb271..ea8a203fb71 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1312,6 +1312,7 @@ export type ModelCompatibility = { reasoningField?: ModelReasoningField maxTokensField?: ModelMaxTokensField requireFinishReason?: boolean + requireAssistantAfterTool?: boolean } export type ModelCost = { diff --git a/packages/core/test/model-resolver.test.ts b/packages/core/test/model-resolver.test.ts index 350acbacc3d..2ef8548c02b 100644 --- a/packages/core/test/model-resolver.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -392,6 +392,7 @@ describe("ModelResolver", () => { reasoningField: "vendor_reasoning", maxTokensField: "max_completion_tokens", requireFinishReason: false, + requireAssistantAfterTool: true, }, settings: { apiKey: "settings-secret", @@ -417,6 +418,7 @@ describe("ModelResolver", () => { expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens") expect(resolved.compatibility?.requireFinishReason).toBe(false) + expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true) expect(prepared.body).toMatchObject({ max_completion_tokens: 10 }) expect(prepared.body).not.toHaveProperty("max_tokens") expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 830afedb946..4177cd8159a 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -57,6 +57,7 @@ export const Compatibility = Schema.Struct({ reasoningField: ReasoningField.pipe(optional), maxTokensField: MaxTokensField.pipe(optional), requireFinishReason: Schema.Boolean.pipe(optional), + requireAssistantAfterTool: Schema.Boolean.pipe(optional), }).annotate({ identifier: "Model.Compatibility" }) export interface Capabilities extends Schema.Schema.Type {} diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index d4429b55712..344020762c2 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -42,11 +42,13 @@ describe("Model.Compatibility", () => { reasoningField: "vendor_reasoning", maxTokensField: "max_completion_tokens", requireFinishReason: false, + requireAssistantAfterTool: true, }), ).toEqual({ reasoningField: "vendor_reasoning", maxTokensField: "max_completion_tokens", requireFinishReason: false, + requireAssistantAfterTool: true, }) }) })