Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/adapters/google_genai/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type { Adapter, AdapterStreamOptions } from "../adapter.ts";
import { getGoogleGenerateContentAPIHistory, rememberGoogleThoughtSignature } from "./history.ts";
import { normalizeGoogleTools } from "./tools.ts";

// Re-exported so wrappers building their own googleGenerateContentAPIModel
// (e.g. a Vertex fallback with inline credentials) can map thinking levels
// the same way geminiModel and vertexAIModel do.
export { getThinkingConfig, type GoogleModels, type SupportedThinkingLevel } from "./models.ts";

export interface GoogleGenerateContentAPIModelOptions {
googleGenAIOptions?: GoogleGenAIOptions;
thinkingConfig?: ThinkingConfig;
Expand Down Expand Up @@ -59,7 +64,8 @@ export function googleGenerateContentAPIModel<zO, zI>(
});
} catch (error) {
if (
!(error instanceof Error) || (
!(error instanceof Error) ||
!(
error.message.includes("generativelanguage.googleapis.com/file_storage_bytes") &&
error.message.includes("429")
)
Expand Down Expand Up @@ -111,6 +117,8 @@ export function googleGenerateContentAPIModel<zO, zI>(
signal,
baseUrl,
ensureFileUploaded,
// Vertex AI does not support the Files API, so file history is sent as inline data.
inlineFiles: options.googleGenAIOptions?.vertexai === true,
});

const stream = await client.models.generateContentStream({
Expand Down
19 changes: 19 additions & 0 deletions src/adapters/google_genai/history.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Content } from "@google/genai";
import { assert } from "@std/assert";
import { encodeBase64 } from "@std/encoding";
import { normalizeToolName } from "../../tool.ts";
import type { ChatItem, ChatItemToolUse } from "../../types.ts";
import { serializeWrappedToolArguments } from "../shared/tools.ts";
Expand Down Expand Up @@ -41,6 +42,8 @@ export async function getGoogleGenerateContentAPIHistory(options: {
signal: AbortSignal;
baseUrl?: string;
ensureFileUploaded?: EnsureFileUploaded;
/** Replay file history as inlineData parts instead of the Files API (unsupported on Vertex AI). */
inlineFiles?: boolean;
}): Promise<Content[]> {
const googleHistory: Content[] = [];
for (const item of options.history) {
Expand Down Expand Up @@ -98,6 +101,22 @@ export async function getGoogleGenerateContentAPIHistory(options: {
}
case "input_file":
case "tool_result_file": {
if (options.inlineFiles) {
const response = await fetch(item.content, { signal: options.signal });
if (!response.ok) {
throw new Error(`Failed to fetch file for inline replay: ${response.status} ${item.content}`);
}
googleHistory.push({
role: "user",
parts: [{
inlineData: {
data: encodeBase64(await response.arrayBuffer()),
mimeType: item.kind,
},
}],
});
break;
}
if (!options.ensureFileUploaded) {
throw new Error("Google history file replay requires an upload handler");
}
Expand Down
7 changes: 5 additions & 2 deletions src/adapters/openai_completions/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,17 @@ export function openAICompletionsModel<zO, zI, TModel extends string>(options: {
const delta = choice.delta as {
content?: string | null;
reasoning?: string | null;
reasoning_content?: string | null;
tool_calls?: Array<{
index?: number;
id?: string;
function?: { name?: string; arguments?: string };
}>;
};

if (delta.reasoning) {
// DeepSeek names the field `reasoning_content`; OpenRouter uses `reasoning`.
const reasoning = delta.reasoning_content || delta.reasoning;
if (reasoning) {
if (lastType !== "reasoning") {
lastType = "reasoning";
lastIndex++;
Expand All @@ -144,7 +147,7 @@ export function openAICompletionsModel<zO, zI, TModel extends string>(options: {
yield {
type: "delta_output_reasoning",
index: lastIndex,
delta: delta.reasoning,
delta: reasoning,
};
}

Expand Down
113 changes: 91 additions & 22 deletions src/adapters/openai_completions/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import type { ChatCompletionMessageParam, ChatCompletionMessageToolCall } from "openai/resources/chat/completions";
import { isStructuredOutputRetryFeedback, RETRY_RESUMABILITY_PROMPT } from "../../constants.ts";
import { normalizeToolName } from "../../tool.ts";
import type { ChatItem, ChatItemInputFile, ChatItemToolResultFile } from "../../types.ts";
Expand Down Expand Up @@ -116,53 +116,120 @@ export async function getOpenAICompletionsHistory<TModel extends string>(options
content: options.instructions,
}];

// DeepSeek rejects a replayed assistant turn that omits `reasoning_content`, so the
// reasoning is buffered and re-attached to the assistant message the turn produced.
// Providers that don't model reasoning this way ignore the extra field.
let turnReasoning = "";

// An assistant message carrying `tool_calls` must be followed by exactly one tool
// message per call id, so a turn's consecutive tool uses are coalesced into one
// assistant message and their results emitted as a contiguous block. File results
// have no tool-role representation, so they trail the block as user messages.
let toolTurn: {
reasoning: string;
calls: ChatCompletionMessageToolCall[];
results: Map<string, string>;
files: ChatCompletionMessageParam[];
} | null = null;

function flushToolTurn() {
if (!toolTurn) return;
// Unlike the text branch, `reasoning_content` is emitted even when empty: DeepSeek
// rejects a tool-call turn that omits the field outright, and compaction can drop the
// reasoning while keeping the use. Every other provider ignores an empty value.
messages.push({
role: "assistant",
content: null,
reasoning_content: toolTurn.reasoning,
tool_calls: toolTurn.calls,
} as ChatCompletionMessageParam);
for (const call of toolTurn.calls) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: toolTurn.results.get(call.id) ?? "",
});
}
messages.push(...toolTurn.files);
toolTurn = null;
turnReasoning = "";
}

for (const historyItem of options.history) {
switch (historyItem.type) {
case "input_text":
flushToolTurn();
turnReasoning = "";
messages.push({
role: "user",
content: historyItem.content,
});
break;
case "output_text":
messages.push({
role: isStructuredOutputRetryFeedback(historyItem.content) ? "user" : "assistant",
content: historyItem.content,
});
flushToolTurn();
if (isStructuredOutputRetryFeedback(historyItem.content)) {
messages.push({ role: "user", content: historyItem.content });
} else {
messages.push({
role: "assistant",
content: historyItem.content,
...(turnReasoning ? { reasoning_content: turnReasoning } : {}),
} as ChatCompletionMessageParam);
}
turnReasoning = "";
break;
case "output_reasoning":
flushToolTurn();
turnReasoning += historyItem.content;
break;
case "context_summary":
flushToolTurn();
turnReasoning = "";
messages.push({
role: "user",
content: historyItem.content,
});
break;
case "tool_use": {
// A use arriving after results belongs to the next turn, not this one.
if (toolTurn && toolTurn.results.size > 0) flushToolTurn();
toolTurn ??= { reasoning: turnReasoning, calls: [], results: new Map(), files: [] };

const tool = options.normalizedTools.find((candidate) => candidate.original.name === historyItem.kind);
messages.push({
role: "assistant",
content: null,
tool_calls: [{
id: historyItem.tool_use_id,
type: "function",
function: {
name: tool?.openAI.function.name ?? normalizeToolName(historyItem.kind),
arguments: serializeWrappedToolArguments(historyItem.content, tool),
},
}],
toolTurn.calls.push({
id: historyItem.tool_use_id,
type: "function",
function: {
name: tool?.openAI.function.name ?? normalizeToolName(historyItem.kind),
arguments: serializeWrappedToolArguments(historyItem.content, tool),
},
});
break;
}
case "tool_result_text":
messages.push({
role: "tool",
tool_call_id: historyItem.tool_use_id,
content: historyItem.content,
});
// A result whose use was compacted away cannot be paired; an unanswerable
// tool message would be rejected outright, so drop it.
toolTurn?.results.set(historyItem.tool_use_id, historyItem.content);
break;
case "tool_result_file": {
if (!toolTurn) break;
if (!toolTurn.results.has(historyItem.tool_use_id)) {
toolTurn.results.set(historyItem.tool_use_id, "");
}
toolTurn.files.push(
await getOpenAICompletionsFileMessage(
options.model,
historyItem,
supportedMimeTypes,
options.pdfSupport,
options.signal,
),
);
break;
}
case "input_file":
case "tool_result_file":
flushToolTurn();
turnReasoning = "";
messages.push(
await getOpenAICompletionsFileMessage(
options.model,
Expand All @@ -178,6 +245,8 @@ export async function getOpenAICompletionsHistory<TModel extends string>(options
}
}

flushToolTurn();

const lastHistoryItem = options.history.at(-1);
if (lastHistoryItem?.type === "output_text" && !isStructuredOutputRetryFeedback(lastHistoryItem.content)) {
messages.push({
Expand Down
42 changes: 41 additions & 1 deletion tests/adapters/gemini.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ThinkingLevel as GenAiThinkingLevel } from "@google/genai";
import { assertEquals } from "@std/assert";
import { encodeBase64 } from "@std/encoding";
import { geminiModel } from "../../src/adapters/gemini/adapter.ts";
import { googleGenerateContentAPIModel } from "../../src/adapters/google_genai/adapter.ts";
import { getThinkingConfig, googleGenerateContentAPIModel } from "../../src/adapters/google_genai/adapter.ts";
import { getGoogleGenerateContentAPIHistory } from "../../src/adapters/google_genai/history.ts";
import {
createToolFixtures,
Expand Down Expand Up @@ -481,3 +482,42 @@ Deno.test("GeminiAdapter replays mixed tool history for a tool-less handoff", as
},
]);
});

Deno.test("GeminiAdapter inlines file history instead of using the Files API", async () => {
const bytes = new TextEncoder().encode("%PDF-1.4 fake body");
const server = Deno.serve({ port: 0, onListen: () => {} }, () => new Response(bytes));
try {
const history = await getGoogleGenerateContentAPIHistory({
history: [{
type: "input_file",
kind: "application/pdf",
content: `http://localhost:${server.addr.port}/file.pdf`,
}],
toolMap: [],
signal: new AbortController().signal,
inlineFiles: true,
});

assertEquals(history, [{
role: "user",
parts: [{
inlineData: { data: encodeBase64(bytes), mimeType: "application/pdf" },
}],
}]);
} finally {
await server.shutdown();
}
});

Deno.test("getThinkingConfig maps levels for legacy-budget and unsupported models", () => {
assertEquals(getThinkingConfig("gemini-2.5-flash", "minimal"), {
includeThoughts: true,
thinkingBudget: 512,
});
// Vertex AI rejects includeThoughts when thinking is disabled, so the
// unsupported branch must keep emitting includeThoughts: false.
assertEquals(getThinkingConfig("gemini-2.0-flash-lite"), {
includeThoughts: false,
thinkingBudget: 0,
});
});
7 changes: 5 additions & 2 deletions tests/adapters/openai-completions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert, assertEquals, assertThrows } from "@std/assert";
import type OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import z from "zod";
import { openAICompletionsModel } from "../../src/adapters/openai_completions/adapter.ts";
import { getOpenAICompletionsHistory } from "../../src/adapters/openai_completions/history.ts";
Expand Down Expand Up @@ -77,6 +78,7 @@ Deno.test("OpenAI Completions history normalizes assistant, retry, tools, and fi
{
role: "assistant",
content: null,
reasoning_content: "I should search first.",
tool_calls: [{
id: "call_1",
type: "function",
Expand All @@ -85,7 +87,7 @@ Deno.test("OpenAI Completions history normalizes assistant, retry, tools, and fi
arguments: '{"content":"cats"}',
},
}],
},
} as ChatCompletionMessageParam,
{ role: "tool", tool_call_id: "call_1", content: "found 2 results" },
{
role: "user",
Expand Down Expand Up @@ -168,6 +170,7 @@ Deno.test("OpenAI Completions replays missing tool definitions with normalized n
{
role: "assistant",
content: null,
reasoning_content: "",
tool_calls: [{
id: "call_1",
type: "function",
Expand All @@ -176,7 +179,7 @@ Deno.test("OpenAI Completions replays missing tool definitions with normalized n
arguments: "{}",
},
}],
},
} as ChatCompletionMessageParam,
{ role: "tool", tool_call_id: "call_1", content: "ready" },
]);
});
Expand Down
Loading