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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.6.10",
"version": "0.6.11",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/google_genai/history.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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";
Expand Down Expand Up @@ -80,7 +79,10 @@ export async function getGoogleGenerateContentAPIHistory(options: {
candidate.type === "tool_use" &&
candidate.tool_use_id === item.tool_use_id
);
assert(toolCall, "Tool result is present in the history without initial tool call");
// A result whose call is gone (compaction cutting a parallel batch, a caller
// assembling its own history) has no name to respond under, and Gemini rejects
// an unpaired response anyway, so it is dropped.
if (!toolCall) break;

// We don't actually assert the definition's existence. Chat history might get reused without previously existing tool calls,
// e.g. for context compaction, or when user wants to implement custom tool selection system.
Expand Down
70 changes: 63 additions & 7 deletions src/adapters/open_responses/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DEFAULT_SUPPORTED_MIME_TYPES } from "../shared/media.ts";
import { createOpenAICompatibleSchema } from "../shared/openai_compatibility.ts";
import { restoreWrappedToolArguments } from "../shared/tools.ts";
import { splitCacheInclusiveUsage } from "../shared/usage.ts";
import { getOpenResponsesHistory } from "./history.ts";
import { getOpenResponsesHistory, type ToolCallReplay } from "./history.ts";
import { normalizeOpenResponsesTools, type OpenResponsesToolMap } from "./tools.ts";
import type { ClientOptions } from "openai";

Expand All @@ -32,6 +32,8 @@ type OpenResponsesStreamingRequest = ResponseCreateParamsStreaming & {

export type OpenResponsesClient = Pick<OpenAI, "responses">;

const TOOL_CALL_REPLAY_LIMIT = 2048;

/** Generic adapter over an Open Responses compatible API */
export function openResponsesModel<zO, zI>(options: {
model: string;
Expand All @@ -47,6 +49,21 @@ export function openResponsesModel<zO, zI>(options: {
const parallelToolCalls = options.parallelToolCalls ?? true;
const supportedMimeTypes = options.supportedMimeTypes ?? DEFAULT_SUPPORTED_MIME_TYPES;

// Only the live tool loop benefits from replaying provider ids, and that always runs in one
// process, so a bounded cache is enough. A miss falls back to synthetic ids, which is what
// every replay did before this existed.
//
// Replaying an id assumes the endpoint still holds the response it came from. That is the
// default on OpenAI; an endpoint that does not retain responses should pass `client`.
const toolCallReplays = new Map<string, ToolCallReplay>();
const rememberToolCallReplay = (toolUseId: string, replay: ToolCallReplay) => {
if (!toolCallReplays.has(toolUseId) && toolCallReplays.size >= TOOL_CALL_REPLAY_LIMIT) {
const oldest = toolCallReplays.keys().next();
if (!oldest.done) toolCallReplays.delete(oldest.value);
}
toolCallReplays.set(toolUseId, replay);
};

return {
provider: options.provider ?? "OpenResponses",
model: options.model,
Expand All @@ -65,6 +82,7 @@ export function openResponsesModel<zO, zI>(options: {
normalizedTools,
signal,
supportedMimeTypes,
toolCallReplays,
});
const pendingToolCallsByOutputIndex: PendingToolCall[] = [];
const pendingToolCallsByItemId: Record<string, PendingToolCall> = {};
Expand Down Expand Up @@ -95,19 +113,35 @@ export function openResponsesModel<zO, zI>(options: {

const response = client.responses.stream(request, { signal });

// Provider indices are per output item, so consecutive summary parts of one reasoning
// item share an output_index and would collapse into a single block. Allocate our own
// index per (output item, summary part) instead, in the order the events arrive.
const streamIndices = new Map<string, number>();
let nextStreamIndex = 0;
let openReasoningItemId: string | undefined;
const streamIndex = (key: string) => {
const existing = streamIndices.get(key);
if (existing !== undefined) return existing;
streamIndices.set(key, nextStreamIndex);
return nextStreamIndex++;
};

for await (const part of response) {
switch (part.type) {
case "response.output_text.delta":
case "response.refusal.delta":
if (part.delta) {
if (shouldRestoreStructuredOutput) {
// Claim the index now so the restored text keeps its position relative to
// reasoning and tool blocks, even though it is only emitted once the stream ends.
streamIndex(`text:${part.output_index}`);
pendingStructuredOutput[part.output_index] ??= "";
pendingStructuredOutput[part.output_index] += part.delta;
} else {
yield {
type: "delta_output_text",
delta: part.delta,
index: part.output_index,
index: streamIndex(`text:${part.output_index}`),
};
}
}
Expand All @@ -118,12 +152,24 @@ export function openResponsesModel<zO, zI>(options: {
yield {
type: "delta_output_reasoning",
delta: part.delta,
index: part.output_index,
index: streamIndex(
"summary_index" in part
? `reasoning:${part.output_index}:summary:${part.summary_index}`
: `reasoning:${part.output_index}:content:${part.content_index}`,
),
};
}
break;
case "response.output_item.added": {
const item = part.item;
if (item.type === "reasoning") {
openReasoningItemId = item.id;
// Peek rather than allocate: this item never becomes a ChatItem, and downstream
// indices are derived from item counts, so consuming one would leave a gap that
// collides a later tool result with the block before it.
yield { type: "reasoning_start", index: nextStreamIndex };
break;
}
if (item.type !== "function_call") break;

const tool = normalizedTools.find((candidate) => candidate.openResponses.name === item.name);
Expand All @@ -138,7 +184,7 @@ export function openResponsesModel<zO, zI>(options: {
}
yield {
type: "tool_use_start",
index: part.output_index,
index: streamIndex(`tool:${part.output_index}`),
tool_use_id: pendingToolCall.tool_use_id,
kind: pendingToolCall.kind,
};
Expand All @@ -155,15 +201,25 @@ export function openResponsesModel<zO, zI>(options: {
if (!pendingToolCall) {
yield {
type: "tool_use_start",
index: part.output_index,
index: streamIndex(`tool:${part.output_index}`),
tool_use_id: toolUseId,
kind,
};
}

// Recorded only once the call is complete, since ids from an abandoned response may
// never have been stored. A preamble message between the two is fine; the API pairs a
// call with any reasoning item from its response, not strictly the preceding one.
if (openReasoningItemId) {
rememberToolCallReplay(toolUseId, {
callItemId: part.item_id,
reasoningItemId: openReasoningItemId,
});
}

yield {
type: "tool_use",
index: part.output_index,
index: streamIndex(`tool:${part.output_index}`),
tool_use_id: toolUseId,
kind,
content: restoreWrappedToolArguments(part.arguments, tool),
Expand All @@ -188,7 +244,7 @@ export function openResponsesModel<zO, zI>(options: {
yield {
type: "delta_output_text",
delta: restoredText,
index,
index: streamIndex(`text:${index}`),
};
}
}
Expand Down
47 changes: 46 additions & 1 deletion src/adapters/open_responses/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ function getSyntheticId(prefix: string) {
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
}

/**
* Provider-issued ids for one tool call. Only usable as a pair and only against the endpoint that
* issued them: replaying a `function_call` id whose reasoning item is absent is rejected outright.
*/
export interface ToolCallReplay {
callItemId: string;
reasoningItemId: string;
}

function createUserTextMessage(text: string, role: "user" | "developer" = "user"): ResponseInputItem {
return {
type: "message",
Expand Down Expand Up @@ -121,9 +130,12 @@ export async function getOpenResponsesHistory(options: {
normalizedTools: OpenResponsesToolMap[];
signal: AbortSignal;
supportedMimeTypes?: string[];
toolCallReplays?: Map<string, ToolCallReplay>;
}): Promise<ResponseInputItem[]> {
const supportedMimeTypes = options.supportedMimeTypes ?? DEFAULT_SUPPORTED_MIME_TYPES;
const responseHistory: ResponseInputItem[] = [];
const calledToolUseIds = new Set<string>();
const replayedReasoningItemIds = new Set<string>();

for (const historyItem of options.history) {
switch (historyItem.type) {
Expand All @@ -146,8 +158,19 @@ export async function getOpenResponsesHistory(options: {
break;
case "tool_use": {
const tool = options.normalizedTools.find((candidate) => candidate.original.name === historyItem.kind);
calledToolUseIds.add(historyItem.tool_use_id);

// Replaying the call's own id lets the model reuse the reasoning that produced it instead
// of deriving the whole chain again. The reasoning item has to precede it, and several
// calls can share one, so it is emitted once for the group.
const replay = options.toolCallReplays?.get(historyItem.tool_use_id);
if (replay && !replayedReasoningItemIds.has(replay.reasoningItemId)) {
replayedReasoningItemIds.add(replay.reasoningItemId);
responseHistory.push({ type: "reasoning", id: replay.reasoningItemId, summary: [] });
}

responseHistory.push({
id: getSyntheticId("fc"),
id: replay?.callItemId ?? getSyntheticId("fc"),
type: "function_call",
status: "completed",
call_id: historyItem.tool_use_id,
Expand All @@ -157,12 +180,16 @@ export async function getOpenResponsesHistory(options: {
break;
}
case "tool_result_text": {
// A result whose call is not in this history cannot be paired, and the API
// rejects the request outright over it, so drop it.
if (!calledToolUseIds.has(historyItem.tool_use_id)) break;
const output = getOrCreateFunctionCallOutput(responseHistory, historyItem.tool_use_id);
assert(typeof output.output !== "string");
output.output.push({ type: "input_text", text: historyItem.content });
break;
}
case "tool_result_file": {
if (!calledToolUseIds.has(historyItem.tool_use_id)) break;
const output = getOrCreateFunctionCallOutput(responseHistory, historyItem.tool_use_id);
assert(typeof output.output !== "string");
output.output.push(
Expand All @@ -183,6 +210,24 @@ export async function getOpenResponsesHistory(options: {
}
}

// A call whose result never made it into the history is rejected the same way an
// unpaired result is, so it answers with nothing rather than losing the whole turn.
const answeredCallIds = new Set(
responseHistory.flatMap((item) => item.type === "function_call_output" ? [item.call_id] : []),
);
for (let index = responseHistory.length - 1; index >= 0; index -= 1) {
const item = responseHistory[index];
if (item.type !== "function_call" || answeredCallIds.has(item.call_id)) continue;

responseHistory.splice(index + 1, 0, {
id: getSyntheticId("fco"),
type: "function_call_output",
call_id: item.call_id,
status: "completed",
output: [{ type: "input_text", text: "" }],
});
}

const lastHistoryItem = options.history.at(-1);
if (lastHistoryItem?.type === "output_text" && !isStructuredOutputRetryFeedback(lastHistoryItem.content)) {
responseHistory.push(createUserTextMessage(RETRY_RESUMABILITY_PROMPT, "developer"));
Expand Down
39 changes: 38 additions & 1 deletion src/adapters/openrouter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ export interface OpenRouterPlugin {
[key: string]: unknown;
}

/**
* OpenRouter reports an upstream 4xx as a flat "Provider returned error" and keeps the
* provider's own message in `metadata.raw`. Folding it into the error message is what lets
* the retry layer see a context overflow for what it is instead of an opaque client error.
*/
function describeOpenRouterProviderError(error: unknown): string | null {
const body = (error as { error?: { message?: unknown; metadata?: { raw?: unknown } } } | null)?.error;
if (typeof body?.message !== "string" || typeof body.metadata?.raw !== "string") return null;

const raw = body.metadata.raw;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return raw;
}

const parsedMessage = (parsed as { error?: { message?: unknown } })?.error?.message;
return typeof parsedMessage === "string" ? parsedMessage : raw;
}

function getOpenRouterHeaders(options: {
headers?: Record<string, string>;
siteUrl?: string;
Expand Down Expand Up @@ -49,7 +70,7 @@ export function openrouterModel<zO, zI, TModel extends OpenRouterModels>(options
extraRequestBody?: Record<string, unknown>;
pdfSupport?: OpenAICompletionsPdfSupport<TModel>;
}): Adapter<zO, zI> {
return openAICompletionsModel({
const adapter = openAICompletionsModel<zO, zI, TModel>({
provider: "OpenRouter",
model: options.model,
client: options.client,
Expand All @@ -70,4 +91,20 @@ export function openrouterModel<zO, zI, TModel extends OpenRouterModels>(options
...(options.extraRequestBody ?? {}),
}),
});

return {
...adapter,
stream: async function* (streamOptions) {
try {
return yield* adapter.stream(streamOptions);
} catch (error) {
const providerError = describeOpenRouterProviderError(error);
// Rewritten in place so the error stays an `OpenAI.APIError` for classification.
if (providerError && error instanceof Error) {
error.message = `${error.message}: ${providerError}`;
}
throw error;
}
},
};
}
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ export async function cli<zO, zI, const Tools extends AnyTool[]>(
break;
}

if (next.value.type === "delta_output_reasoning" && !announcedThinking) {
if (
(next.value.type === "reasoning_start" || next.value.type === "delta_output_reasoning") &&
!announcedThinking
) {
announcedThinking = true;
io.write("[thinking]\n");
} else if (next.value.type === "tool_use_start") {
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function createChatItemFromStreamItem(streamItem: StreamItem): ChatItem {
};
case "token_usage":
case "context_summary_start":
case "reasoning_start":
case "model_switched":
throw new Error(
`Cannot convert informational stream item "${streamItem.type}" into ChatItem.`,
Expand All @@ -97,6 +98,7 @@ export function addStreamItem<T extends ChatItem>(
if (
streamItem.type === "token_usage" ||
streamItem.type === "context_summary_start" ||
streamItem.type === "reasoning_start" ||
streamItem.type === "model_switched"
) {
return;
Expand Down
Loading
Loading