mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into pico
This commit is contained in:
commit
01528e2094
219 changed files with 7101 additions and 5424 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -5224,6 +5224,7 @@
|
|||
"@earendil-works/pi-ai": "^0.85.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.85.1",
|
||||
"@types/node": "22.19.19",
|
||||
"@vitest-evals/core": "0.15.0",
|
||||
"shx": "0.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.9",
|
||||
|
|
|
|||
|
|
@ -181,7 +181,8 @@ The last message in context must be `user` or `toolResult` (not `assistant`).
|
|||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
// Initial state
|
||||
// Initial state. systemPrompt and tools become the leading system message
|
||||
// unless messages already starts with one.
|
||||
initialState: {
|
||||
systemPrompt: string,
|
||||
model: Model<any>,
|
||||
|
|
@ -202,7 +203,8 @@ const agent = new Agent({
|
|||
// Follow-up mode: "one-at-a-time" (default) or "all"
|
||||
followUpMode: "one-at-a-time",
|
||||
|
||||
// Required stream function
|
||||
// Required stream function. Receives a TranscriptContext: the prompt and tools
|
||||
// are in the transcript's system messages, not on the context.
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
|
||||
// Session ID for provider caching
|
||||
|
|
@ -250,7 +252,6 @@ const agent = new Agent({
|
|||
|
||||
```typescript
|
||||
interface AgentState {
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
|
|
@ -266,6 +267,17 @@ Access state via `agent.state`.
|
|||
|
||||
Assigning `agent.state.tools = [...]` or `agent.state.messages = [...]` copies the top-level array before storing it. Mutating the returned array mutates the current agent state.
|
||||
|
||||
The transcript owns the system prompt and tool declarations: the leading system message is the prompt, later system messages patch it (see `SystemMessage` in pi-ai). `agent.state.systemPrompt` is read-only and replays the transcript. `agent.state.tools` is the executable loadout; before every request the loop diffs it against the tools the transcript declares and, if they differ, announces the change in a system message (merged into a pending system message when one exists). pi-ai's `getCurrentSystemMessage(messages)` returns the replayed head, including declared tools, for any message array, including agent transcripts with custom message roles.
|
||||
|
||||
To change the prompt mid-conversation, append a system message with `content` (added instructions) or `sections` (named replacements):
|
||||
|
||||
```typescript
|
||||
await agent.prompt([
|
||||
{ role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
|
||||
{ role: "user", content: "Continue", timestamp: Date.now() },
|
||||
]);
|
||||
```
|
||||
|
||||
During streaming, `agent.state.streamingMessage` contains the current partial assistant message.
|
||||
|
||||
`agent.state.isStreaming` remains `true` until the run fully settles, including awaited `agent_end` subscribers.
|
||||
|
|
@ -293,7 +305,6 @@ await agent.continue();
|
|||
### State Management
|
||||
|
||||
```typescript
|
||||
agent.state.systemPrompt = "New prompt";
|
||||
agent.state.model = getModel("openai", "gpt-4o");
|
||||
agent.state.thinkingLevel = "medium";
|
||||
agent.state.tools = [myTool];
|
||||
|
|
@ -484,8 +495,7 @@ For direct control without the Agent class:
|
|||
import { agentLoop, agentLoopContinue } from "@earendil-works/pi-agent-core";
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
messages: [{ role: "system", content: "You are helpful.", timestamp: Date.now() }],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ append.
|
|||
|
||||
The harness still assembles `AgentToolResult` in process for the model, and
|
||||
`createToolResultMessage` still writes the settled `ToolResultMessage` to the
|
||||
transcript with `content`, `details`, `usage`, `addedToolNames`, `isError`.
|
||||
transcript with `content`, `details`, `usage`, `isError`.
|
||||
Neither is a wire event.
|
||||
|
||||
## 6. Lane reduction
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ The staged message contains the final:
|
|||
- added tool names;
|
||||
- timestamp.
|
||||
|
||||
`terminate` remains orchestration state because it controls the batch continuation and is copied to the immutable entry's `terminate` field at placement. Staged `addedToolNames` do not affect the active tool set until that result materializes in the transcript.
|
||||
`terminate` remains orchestration state because it controls the batch continuation and is copied to the immutable entry's `terminate` field at placement.
|
||||
|
||||
## Source-ordered materialization
|
||||
|
||||
|
|
@ -384,7 +384,7 @@ Example final text suffix:
|
|||
Rules:
|
||||
|
||||
- do not run `after_tool` for this synthetic result;
|
||||
- preserve checkpoint `usage` when present, but ignore checkpoint `addedToolNames` and `terminate` because progress never has final-result authority;
|
||||
- preserve checkpoint `usage` when present, but ignore checkpoint `terminate` because progress never has final-result authority;
|
||||
- set `terminate: false` and add no tools;
|
||||
- an absent checkpoint value is also valid and yields only the interruption result;
|
||||
- never infer completion from an apparent success line in partial output;
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ Skip. Its `toolResult` entry must already be in the captured transcript.
|
|||
- Reconstruct the canonical `AgentToolResult` from the staged `ToolResultMessage` and the durable call termination flag as needed.
|
||||
- Project `status: "settled"`, `result`, and `isError`.
|
||||
|
||||
The event and capture representations must normalize the final result identically so folding through `tool_end` equals a later authoritative snapshot. Pay attention to optional `details`, `usage`, `addedToolNames`, and `terminate`; do not rely on incidental object-property presence differences.
|
||||
The event and capture representations must normalize the final result identically so folding through `tool_end` equals a later authoritative snapshot. Pay attention to optional `details`, `usage`, and `terminate`; do not rely on incidental object-property presence differences.
|
||||
|
||||
A missing or mismatched staged result for `outcome_ready` is presentation corruption and must fault snapshot capture.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,14 @@
|
|||
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
EventStream,
|
||||
getCurrentTools,
|
||||
getToolStateChanges,
|
||||
normalizeContext,
|
||||
type SystemMessage,
|
||||
type ToolResultMessage,
|
||||
type ToolStateChanges,
|
||||
toToolDeclaration,
|
||||
validateToolArguments,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||
|
|
@ -101,17 +106,18 @@ export async function runAgentLoop(
|
|||
signal: AbortSignal | undefined,
|
||||
streamFn: StreamFn,
|
||||
): Promise<AgentMessage[]> {
|
||||
const newMessages: AgentMessage[] = [...prompts];
|
||||
const initialMessages = declareToolChanges(context, prompts);
|
||||
const newMessages: AgentMessage[] = [...initialMessages];
|
||||
const currentContext: AgentContext = {
|
||||
...context,
|
||||
messages: [...context.messages, ...prompts],
|
||||
messages: [...context.messages, ...initialMessages],
|
||||
};
|
||||
|
||||
await emit({ type: "agent_start" });
|
||||
await emit({ type: "turn_start" });
|
||||
for (const prompt of prompts) {
|
||||
await emit({ type: "message_start", message: prompt });
|
||||
await emit({ type: "message_end", message: prompt });
|
||||
for (const message of initialMessages) {
|
||||
await emit({ type: "message_start", message });
|
||||
await emit({ type: "message_end", message });
|
||||
}
|
||||
|
||||
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
|
||||
|
|
@ -173,10 +179,12 @@ async function runLoop(
|
|||
|
||||
// Inner loop: process tool calls and steering messages
|
||||
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
||||
let preparedMessages: AgentMessage[] = [];
|
||||
if (lastCompletedTurn) {
|
||||
const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
|
||||
if (nextTurnSnapshot) {
|
||||
currentContext = nextTurnSnapshot.context ?? currentContext;
|
||||
preparedMessages = nextTurnSnapshot.messages ?? [];
|
||||
config = {
|
||||
...config,
|
||||
model: nextTurnSnapshot.model ?? config.model,
|
||||
|
|
@ -197,16 +205,14 @@ async function runLoop(
|
|||
await emit({ type: "turn_start" });
|
||||
}
|
||||
|
||||
// Process pending messages (inject before next assistant response)
|
||||
if (pendingMessages.length > 0) {
|
||||
for (const message of pendingMessages) {
|
||||
await emit({ type: "message_start", message });
|
||||
await emit({ type: "message_end", message });
|
||||
currentContext.messages.push(message);
|
||||
newMessages.push(message);
|
||||
}
|
||||
pendingMessages = [];
|
||||
// Process prepared and queued messages before the next assistant response.
|
||||
for (const message of declareToolChanges(currentContext, [...preparedMessages, ...pendingMessages])) {
|
||||
await emit({ type: "message_start", message });
|
||||
await emit({ type: "message_end", message });
|
||||
currentContext.messages.push(message);
|
||||
newMessages.push(message);
|
||||
}
|
||||
pendingMessages = [];
|
||||
|
||||
// Stream assistant response
|
||||
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
|
||||
|
|
@ -272,6 +278,60 @@ async function runLoop(
|
|||
await emit({ type: "agent_end", messages: newMessages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare tool loadout changes to the model.
|
||||
*
|
||||
* `context.tools` is what the runtime can execute; the transcript's system messages declare
|
||||
* what the model may call. Before each request the difference becomes `toolsAdded` and
|
||||
* `toolsRemoved` on a system message. When a pending system message exists, its tool fields
|
||||
* are treated as intent and replaced with the delta between the committed transcript and
|
||||
* the executable set, so replay always yields exactly `context.tools`. Otherwise a new
|
||||
* system message is inserted before the first non-system pending message.
|
||||
*/
|
||||
function declareToolChanges(context: AgentContext, pendingMessages: AgentMessage[]): AgentMessage[] {
|
||||
let systemIndex = -1;
|
||||
for (let i = pendingMessages.length - 1; i >= 0; i--) {
|
||||
if (pendingMessages[i].role === "system") {
|
||||
systemIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pending = pendingMessages[systemIndex] as SystemMessage | undefined;
|
||||
const baseline = pending
|
||||
? pendingMessages.map((message, index) =>
|
||||
index === systemIndex ? withToolChanges(pending, NO_CHANGES) : message,
|
||||
)
|
||||
: pendingMessages;
|
||||
const changes = getToolStateChanges(
|
||||
getCurrentTools([...context.messages, ...baseline]),
|
||||
(context.tools ?? []).map(toToolDeclaration),
|
||||
);
|
||||
const unchanged = changes.toolsAdded.length === 0 && changes.toolsRemoved.length === 0;
|
||||
|
||||
if (pending) {
|
||||
// Keep the caller's message object when it already declares no tool changes.
|
||||
if (unchanged && !pending.toolsAdded?.length && !pending.toolsRemoved?.length) return pendingMessages;
|
||||
return baseline.map((message, index) => (index === systemIndex ? withToolChanges(pending, changes) : message));
|
||||
}
|
||||
if (unchanged) return pendingMessages;
|
||||
const update = withToolChanges({ role: "system", content: "", timestamp: Date.now() }, changes);
|
||||
const insertIndex = pendingMessages.findIndex((message) => message.role !== "system");
|
||||
const index = insertIndex === -1 ? pendingMessages.length : insertIndex;
|
||||
return [...pendingMessages.slice(0, index), update, ...pendingMessages.slice(index)];
|
||||
}
|
||||
|
||||
const NO_CHANGES: ToolStateChanges = { toolsAdded: [], toolsRemoved: [] };
|
||||
|
||||
/** Copy a system message with its tool fields replaced by `changes`; empty lists omit the field. */
|
||||
function withToolChanges(message: SystemMessage, { toolsAdded, toolsRemoved }: ToolStateChanges): SystemMessage {
|
||||
const { toolsAdded: _added, toolsRemoved: _removed, ...rest } = message;
|
||||
return {
|
||||
...rest,
|
||||
...(toolsAdded.length > 0 ? { toolsAdded } : {}),
|
||||
...(toolsRemoved.length > 0 ? { toolsRemoved } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an assistant response from the LLM.
|
||||
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
|
||||
|
|
@ -292,12 +352,7 @@ async function streamAssistantResponse(
|
|||
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
||||
const llmMessages = await config.convertToLlm(messages);
|
||||
|
||||
// Build LLM context
|
||||
const llmContext: Context = {
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: llmMessages,
|
||||
tools: context.tools,
|
||||
};
|
||||
const llmContext = normalizeContext({ messages: llmMessages });
|
||||
|
||||
// Resolve API key (important for expiring tokens)
|
||||
const resolvedApiKey =
|
||||
|
|
@ -791,7 +846,6 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
|
|||
content: finalized.result.content ?? [],
|
||||
details: finalized.result.details,
|
||||
usage: finalized.result.usage,
|
||||
...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}),
|
||||
isError: finalized.isError,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import type {
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
ThinkingBudgets,
|
||||
Transport,
|
||||
import {
|
||||
createInitialSystemMessage,
|
||||
getCurrentSystemMessage,
|
||||
getCurrentSystemPrompt,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type TextContent,
|
||||
type ThinkingBudgets,
|
||||
type Transport,
|
||||
toToolDeclaration,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
|
||||
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||
|
|
@ -32,7 +36,11 @@ export type { QueueMode } from "./types.ts";
|
|||
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
(message) =>
|
||||
message.role === "system" ||
|
||||
message.role === "user" ||
|
||||
message.role === "assistant" ||
|
||||
message.role === "toolResult",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -65,14 +73,21 @@ type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "
|
|||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function createMutableAgentState(
|
||||
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>,
|
||||
): MutableAgentState {
|
||||
/** Initial state for {@link Agent}. `systemPrompt` and `tools` become the leading system message unless `messages` already starts with one. */
|
||||
export type AgentInitialState = Partial<
|
||||
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
|
||||
>;
|
||||
|
||||
function createMutableAgentState(initialState?: AgentInitialState): MutableAgentState {
|
||||
let tools = initialState?.tools?.slice() ?? [];
|
||||
let messages = initialState?.messages?.slice() ?? [];
|
||||
const initialMessage = createInitialSystemMessage(initialState?.systemPrompt, tools.map(toToolDeclaration));
|
||||
if (messages[0]?.role !== "system" && initialMessage) messages.unshift(initialMessage);
|
||||
|
||||
return {
|
||||
systemPrompt: initialState?.systemPrompt ?? "",
|
||||
get systemPrompt() {
|
||||
return getCurrentSystemPrompt(messages);
|
||||
},
|
||||
model: initialState?.model ?? DEFAULT_MODEL,
|
||||
thinkingLevel: initialState?.thinkingLevel ?? "off",
|
||||
get tools() {
|
||||
|
|
@ -96,7 +111,7 @@ function createMutableAgentState(
|
|||
|
||||
/** Options for constructing an {@link Agent}. */
|
||||
export interface AgentOptions {
|
||||
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
|
||||
initialState?: AgentInitialState;
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
streamFn: StreamFn;
|
||||
|
|
@ -329,13 +344,14 @@ export class Agent {
|
|||
return this.activeRun?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Clear transcript state, runtime state, and queued messages. */
|
||||
/** Clear conversation state and queues while retaining the replayed prompt/tool baseline. */
|
||||
reset(): void {
|
||||
if (this.activeRun) {
|
||||
throw new Error("Agent is already processing. Wait for completion before resetting.");
|
||||
}
|
||||
|
||||
this._state.messages = [];
|
||||
const baseline = getCurrentSystemMessage(this._state.messages);
|
||||
this._state.messages = baseline ? [baseline] : [];
|
||||
this._state.isStreaming = false;
|
||||
this._state.streamingMessage = undefined;
|
||||
this._state.pendingToolCalls = new Set<string>();
|
||||
|
|
@ -364,7 +380,7 @@ export class Agent {
|
|||
}
|
||||
|
||||
const lastMessage = this._state.messages[this._state.messages.length - 1];
|
||||
if (!lastMessage) {
|
||||
if (!lastMessage || this._state.messages.every((message) => message.role === "system")) {
|
||||
throw new Error("No messages to continue from");
|
||||
}
|
||||
|
||||
|
|
@ -436,7 +452,6 @@ export class Agent {
|
|||
|
||||
private createContextSnapshot(): AgentContext {
|
||||
return {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
messages: this._state.messages.slice(),
|
||||
tools: this._state.tools.slice(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -189,7 +189,6 @@ export function toolResultFromMessage(
|
|||
content: message.content,
|
||||
details: message.details,
|
||||
...(message.usage === undefined ? {} : { usage: message.usage }),
|
||||
...(message.addedToolNames === undefined ? {} : { addedToolNames: message.addedToolNames }),
|
||||
...(terminate ? { terminate: true } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -203,7 +202,6 @@ export function createToolResultMessage(call: FinalizedToolCall): ToolResultMess
|
|||
content: call.result.content ?? [],
|
||||
...(call.result.details === undefined ? {} : { details: call.result.details }),
|
||||
...(call.result.usage === undefined ? {} : { usage: call.result.usage }),
|
||||
...(call.result.addedToolNames?.length ? { addedToolNames: call.result.addedToolNames } : {}),
|
||||
isError: call.isError,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
|
|||
],
|
||||
timestamp: m.timestamp,
|
||||
};
|
||||
case "system":
|
||||
case "user":
|
||||
case "assistant":
|
||||
case "toolResult":
|
||||
|
|
|
|||
|
|
@ -15,14 +15,7 @@ import {
|
|||
type UsageRow,
|
||||
type Write,
|
||||
} from "../../session/types.ts";
|
||||
import {
|
||||
branchTip,
|
||||
deleteValue,
|
||||
laneConfig,
|
||||
operationToolArgsPrefix,
|
||||
pendingEntry,
|
||||
setValue,
|
||||
} from "../../session/values.ts";
|
||||
import { branchTip, deleteValue, operationToolArgsPrefix, pendingEntry, setValue } from "../../session/values.ts";
|
||||
import type { Lane } from "../lane.ts";
|
||||
import type { Drive } from "../types.ts";
|
||||
|
||||
|
|
@ -198,22 +191,6 @@ async function commitPlacement<TContext extends object | undefined>(
|
|||
};
|
||||
});
|
||||
const complete = completedCalls.every((call) => call.status === "completed");
|
||||
let nextConfiguration = state.configuration;
|
||||
const addedNames: string[] = [];
|
||||
for (const item of read.items) {
|
||||
for (const name of item.message.addedToolNames ?? []) {
|
||||
if (!nextConfiguration.activeToolNames.includes(name) && !addedNames.includes(name)) {
|
||||
addedNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addedNames.length !== 0) {
|
||||
nextConfiguration = {
|
||||
...nextConfiguration,
|
||||
activeToolNames: [...nextConfiguration.activeToolNames, ...addedNames],
|
||||
};
|
||||
writes.push(setValue(laneConfig(lane.name), nextConfiguration));
|
||||
}
|
||||
writes.push(setValue(branchTip(lane.name), parentId));
|
||||
|
||||
let nextRun: OperationState;
|
||||
|
|
@ -240,7 +217,7 @@ async function commitPlacement<TContext extends object | undefined>(
|
|||
kind: "commit",
|
||||
writes,
|
||||
operationState: nextRun,
|
||||
lane: { tipId: parentId, configuration: nextConfiguration },
|
||||
lane: { tipId: parentId, configuration: state.configuration },
|
||||
materialize: () => complete,
|
||||
events: (commit) => {
|
||||
const events: HarnessEvent[] = [];
|
||||
|
|
@ -260,15 +237,6 @@ async function commitPlacement<TContext extends object | undefined>(
|
|||
});
|
||||
}
|
||||
}
|
||||
if (addedNames.length !== 0) {
|
||||
events.push({
|
||||
type: "config_update",
|
||||
lane: lane.name,
|
||||
property: "activeTools",
|
||||
previous: state.configuration.activeToolNames,
|
||||
value: nextConfiguration.activeToolNames,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
type Context,
|
||||
EventStream,
|
||||
type Model,
|
||||
parseStreamingJson,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type ToolCall,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
|
||||
// Create stream class matching ProxyMessageEventStream
|
||||
|
|
@ -117,7 +117,11 @@ function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializabl
|
|||
};
|
||||
}
|
||||
|
||||
export function streamProxy(model: Model<any>, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream {
|
||||
export function streamProxy(
|
||||
model: Model<any>,
|
||||
context: TranscriptContext,
|
||||
options: ProxyStreamOptions,
|
||||
): ProxyMessageEventStream {
|
||||
const stream = new ProxyMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
|
|
@ -11,6 +10,7 @@ import type {
|
|||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
|
|
@ -19,6 +19,10 @@ import type { Static, TSchema } from "typebox";
|
|||
* Stream function used by the agent loop. `Models.streamSimple` satisfies
|
||||
* this shape.
|
||||
*
|
||||
* The loop passes a normalized transcript: the system prompt and tool
|
||||
* declarations are carried by the transcript's system messages, never by
|
||||
* `context.systemPrompt` or `context.tools`.
|
||||
*
|
||||
* Contract:
|
||||
* - Must not throw or return a rejected promise for request/model/runtime failures.
|
||||
* - Must return an AssistantMessageEventStream.
|
||||
|
|
@ -27,7 +31,7 @@ import type { Static, TSchema } from "typebox";
|
|||
*/
|
||||
export type StreamFn = (
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
||||
|
||||
|
|
@ -138,6 +142,8 @@ export interface ShouldStopAfterTurnContext {
|
|||
export interface AgentLoopTurnUpdate {
|
||||
/** Context for the next provider request. */
|
||||
context?: AgentContext;
|
||||
/** Messages to append before the next provider request, with normal lifecycle events. */
|
||||
messages?: AgentMessage[];
|
||||
/** Model for the next provider request. */
|
||||
model?: Model<any>;
|
||||
/** Thinking level for the next provider request. */
|
||||
|
|
@ -152,7 +158,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
*
|
||||
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
|
||||
* Each AgentMessage must be converted to a SystemMessage, UserMessage, AssistantMessage, or ToolResultMessage
|
||||
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
|
||||
* status messages) should be filtered out.
|
||||
*
|
||||
|
|
@ -224,7 +230,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|||
|
||||
/**
|
||||
* Called after `turn_end` when the loop will continue, immediately before the next turn starts.
|
||||
* Return replacement context/model/thinking state to affect that turn.
|
||||
* Return replacement context/model/thinking state or messages to append to affect that turn.
|
||||
* Return undefined to keep using the current context/config.
|
||||
*/
|
||||
prepareNextTurn?: (
|
||||
|
|
@ -332,16 +338,30 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag
|
|||
* assigned arrays before storing them.
|
||||
*/
|
||||
export interface AgentState {
|
||||
/** System prompt sent with each model request. */
|
||||
systemPrompt: string;
|
||||
/**
|
||||
* Current system prompt, replayed from the transcript's system messages.
|
||||
*
|
||||
* Read-only: to change the prompt, append a system message with `content` or `sections`.
|
||||
* In `initialState`, this seeds the leading system message.
|
||||
*/
|
||||
readonly systemPrompt: string;
|
||||
/** Active model used for future turns. */
|
||||
model: Model<any>;
|
||||
/** Requested reasoning level for future turns. */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
/** Available tools. Assigning a new array copies the top-level array. */
|
||||
/**
|
||||
* Executable tools. Assigning a new array copies the top-level array.
|
||||
*
|
||||
* Differences from the tools declared in the transcript are announced to the model
|
||||
* with a system message before the next request.
|
||||
*/
|
||||
set tools(tools: AgentTool<any>[]);
|
||||
get tools(): AgentTool<any>[];
|
||||
/** Conversation transcript. Assigning a new array copies the top-level array. */
|
||||
/**
|
||||
* Conversation transcript. Assigning a new array copies the top-level array.
|
||||
*
|
||||
* System messages in the transcript carry the prompt and tool declarations.
|
||||
*/
|
||||
set messages(messages: AgentMessage[]);
|
||||
get messages(): AgentMessage[];
|
||||
/**
|
||||
|
|
@ -366,8 +386,6 @@ export interface AgentToolResult<T> {
|
|||
details: T;
|
||||
/** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */
|
||||
usage?: Usage;
|
||||
/** Names of tools introduced by this result and available from this transcript point onward. */
|
||||
addedToolNames?: string[];
|
||||
/**
|
||||
* Hint that the agent should stop after the current tool batch.
|
||||
* Early termination only happens when every finalized tool result in the batch sets this to true.
|
||||
|
|
@ -413,11 +431,9 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|||
|
||||
/** Context snapshot passed into the low-level agent loop. */
|
||||
export interface AgentContext {
|
||||
/** System prompt included with the request. */
|
||||
systemPrompt: string;
|
||||
/** Transcript visible to the model. */
|
||||
messages: AgentMessage[];
|
||||
/** Tools available for this run. */
|
||||
/** Tools available for execution in this run. */
|
||||
tools?: AgentTool<any>[];
|
||||
}
|
||||
|
||||
|
|
@ -435,7 +451,7 @@ export type AgentEvent =
|
|||
// Turn lifecycle - a turn is one assistant response + any tool calls/results
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
// Message lifecycle - emitted for user, assistant, and toolResult messages
|
||||
// Message lifecycle - emitted for system, user, assistant, and toolResult messages
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
// Only emitted for assistant messages during streaming
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ function createUserMessage(text: string): UserMessage {
|
|||
|
||||
// Simple identity converter for tests - just passes through standard messages
|
||||
function identityConverter(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
||||
return messages.filter(
|
||||
(m) => m.role === "system" || m.role === "user" || m.role === "assistant" || m.role === "toolResult",
|
||||
) as Message[];
|
||||
}
|
||||
|
||||
describe("default stream function compatibility", () => {
|
||||
|
|
@ -98,7 +100,7 @@ describe("default stream function compatibility", () => {
|
|||
});
|
||||
|
||||
try {
|
||||
const context: AgentContext = { systemPrompt: "", messages: [], tools: [] };
|
||||
const context: AgentContext = { messages: [], tools: [] };
|
||||
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };
|
||||
const stream = Reflect.apply(agentLoop, undefined, [
|
||||
[createUserMessage("Hello")],
|
||||
|
|
@ -118,7 +120,6 @@ describe("default stream function compatibility", () => {
|
|||
describe("agentLoop with AgentMessage", () => {
|
||||
it("should emit events with AgentMessage types", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
|
|
@ -163,6 +164,45 @@ describe("agentLoop with AgentMessage", () => {
|
|||
expect(eventTypes).toContain("agent_end");
|
||||
});
|
||||
|
||||
it("should build provider context exclusively from transcript messages", async () => {
|
||||
const initialSystem: AgentMessage = {
|
||||
role: "system",
|
||||
content: "Transcript prompt",
|
||||
toolsAdded: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
const context: AgentContext = {
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
const config: AgentLoopConfig = {
|
||||
model: createModel(),
|
||||
convertToLlm: identityConverter,
|
||||
};
|
||||
const stream = agentLoop(
|
||||
[initialSystem, createUserMessage("Hello")],
|
||||
context,
|
||||
config,
|
||||
undefined,
|
||||
(_model, providerContext) => {
|
||||
// The provider receives a transcript: no top-level prompt or tool fields.
|
||||
expect(Object.keys(providerContext)).toEqual(["messages"]);
|
||||
expect(providerContext.messages[0]).toBe(initialSystem);
|
||||
const response = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
response.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: createAssistantMessage([{ type: "text", text: "done" }]),
|
||||
});
|
||||
});
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
});
|
||||
|
||||
it("should handle custom message types via convertToLlm", async () => {
|
||||
// Create a custom message type
|
||||
interface CustomNotification {
|
||||
|
|
@ -178,7 +218,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [notification as unknown as AgentMessage], // Custom message in context
|
||||
tools: [],
|
||||
};
|
||||
|
|
@ -220,7 +259,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
|
||||
it("should apply transformContext before convertToLlm", async () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [
|
||||
createUserMessage("old message 1"),
|
||||
createAssistantMessage([{ type: "text", text: "old response 1" }]),
|
||||
|
|
@ -307,7 +345,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -386,7 +423,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -459,7 +495,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -538,7 +573,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -613,7 +647,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -696,7 +729,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -815,7 +847,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [slowTool],
|
||||
};
|
||||
|
|
@ -909,7 +940,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [slowTool, fastTool],
|
||||
};
|
||||
|
|
@ -985,7 +1015,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1043,11 +1072,10 @@ describe("agentLoop with AgentMessage", () => {
|
|||
},
|
||||
};
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "first prompt",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
let convertedSecondTurnSystemPrompt = "";
|
||||
let convertedSecondTurnHasUpdate = false;
|
||||
let prepareCalls = 0;
|
||||
let prepared = false;
|
||||
const config: AgentLoopConfig = {
|
||||
|
|
@ -1059,10 +1087,10 @@ describe("agentLoop with AgentMessage", () => {
|
|||
prepared = true;
|
||||
return {
|
||||
context: {
|
||||
systemPrompt: "second prompt",
|
||||
messages: currentContext.messages.slice(),
|
||||
tools: currentContext.tools,
|
||||
},
|
||||
messages: [{ role: "system", content: "updated guidance", timestamp: 1 }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1071,7 +1099,9 @@ describe("agentLoop with AgentMessage", () => {
|
|||
const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, (_model, ctx) => {
|
||||
llmCalls++;
|
||||
if (llmCalls === 2) {
|
||||
convertedSecondTurnSystemPrompt = ctx.systemPrompt ?? "";
|
||||
convertedSecondTurnHasUpdate = ctx.messages.some(
|
||||
(message) => message.role === "system" && message.content === "updated guidance",
|
||||
);
|
||||
}
|
||||
const mockStream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
|
|
@ -1101,7 +1131,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
|
||||
expect(llmCalls).toBe(2);
|
||||
expect(prepareCalls).toBe(1);
|
||||
expect(convertedSecondTurnSystemPrompt).toBe("second prompt");
|
||||
expect(convertedSecondTurnHasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it("should stop after the current turn when shouldStopAfterTurn returns true", async () => {
|
||||
|
|
@ -1122,7 +1152,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1183,8 +1212,9 @@ describe("agentLoop with AgentMessage", () => {
|
|||
expect(steeringPolls).toBe(1);
|
||||
expect(followUpPolls).toBe(0);
|
||||
expect(callbackToolResultIds).toEqual(["tool-1"]);
|
||||
expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
|
||||
expect(callbackContextRoles).toEqual(["system", "user", "assistant", "toolResult"]);
|
||||
// The context declares no tools, so the loop announces the loadout with a system message.
|
||||
expect(messages.map((message) => message.role)).toEqual(["system", "user", "assistant", "toolResult"]);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
|
|
@ -1192,6 +1222,8 @@ describe("agentLoop with AgentMessage", () => {
|
|||
"message_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"tool_execution_start",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
|
|
@ -1218,7 +1250,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1249,7 +1280,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
|
||||
const messages = await stream.result();
|
||||
expect(llmCalls).toBe(1);
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
|
||||
expect(messages.map((message) => message.role)).toEqual(["system", "user", "assistant", "toolResult"]);
|
||||
expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -1270,7 +1301,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
},
|
||||
};
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1329,7 +1359,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
},
|
||||
};
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1388,7 +1417,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1428,6 +1456,7 @@ describe("agentLoop with AgentMessage", () => {
|
|||
const messages = await stream.result();
|
||||
expect(callIndex).toBe(2);
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
"system",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
|
|
@ -1452,7 +1481,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [tool],
|
||||
};
|
||||
|
|
@ -1488,7 +1516,6 @@ describe("agentLoop with AgentMessage", () => {
|
|||
describe("agentLoopContinue with AgentMessage", () => {
|
||||
it("should throw when context has no messages", () => {
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [],
|
||||
tools: [],
|
||||
};
|
||||
|
|
@ -1509,7 +1536,6 @@ describe("agentLoopContinue with AgentMessage", () => {
|
|||
const userMessage: AgentMessage = createUserMessage("Hello");
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [userMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
|
@ -1562,7 +1588,6 @@ describe("agentLoopContinue with AgentMessage", () => {
|
|||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [customMessage as unknown as AgentMessage],
|
||||
tools: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEvent,
|
||||
EventStream,
|
||||
getCurrentSystemMessage,
|
||||
getModel,
|
||||
toToolDeclaration,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -46,6 +53,16 @@ function createAssistantMessage(text: string): AssistantMessage {
|
|||
|
||||
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
|
||||
function createTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
|
|
@ -107,7 +124,6 @@ describe("Agent", () => {
|
|||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
expect(agent.state).toBeDefined();
|
||||
expect(agent.state.systemPrompt).toBe("");
|
||||
expect(agent.state.model).toBeDefined();
|
||||
expect(agent.state.thinkingLevel).toBe("off");
|
||||
expect(agent.state.tools).toEqual([]);
|
||||
|
|
@ -129,11 +145,176 @@ describe("Agent", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(agent.state.systemPrompt).toBe("You are a helpful assistant.");
|
||||
expect(agent.state.messages).toEqual([{ role: "system", content: "You are a helpful assistant.", timestamp: 0 }]);
|
||||
expect(agent.state.model).toBe(customModel);
|
||||
expect(agent.state.thinkingLevel).toBe("low");
|
||||
});
|
||||
|
||||
it("converts initial prompt and tools into transcript state", () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [tool] },
|
||||
streamFn: unusedStreamFunction,
|
||||
});
|
||||
|
||||
const initial = agent.state.messages[0];
|
||||
expect(initial?.role).toBe("system");
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.content).toBe("You are helpful.");
|
||||
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
it("declares tool loadout changes to the model before the next request", async () => {
|
||||
const first = createTool("first");
|
||||
const second = createTool("second");
|
||||
const requests: string[][] = [];
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [first] },
|
||||
streamFn: (_model, context) => {
|
||||
requests.push(
|
||||
context.messages.flatMap((message) =>
|
||||
message.role === "system"
|
||||
? [
|
||||
`+${(message.toolsAdded ?? []).map((tool) => tool.name).join(",")}`,
|
||||
`-${(message.toolsRemoved ?? []).map((tool) => tool.name).join(",")}`,
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("one");
|
||||
agent.state.tools = [second];
|
||||
await agent.prompt("two");
|
||||
await agent.prompt("three");
|
||||
|
||||
expect(requests).toEqual([
|
||||
["+first", "-"],
|
||||
["+first", "-", "+second", "-first"],
|
||||
["+first", "-", "+second", "-first"],
|
||||
]);
|
||||
const update = agent.state.messages.find((message) => message.role === "system" && message.toolsRemoved);
|
||||
expect(update).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
|
||||
toolsRemoved: [{ name: "first" }],
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
const initial = agent.state.messages[0];
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.toolsAdded?.[0]).not.toHaveProperty("execute");
|
||||
});
|
||||
|
||||
it("merges tool changes into a pending system message", async () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful." },
|
||||
streamFn: (_model, context) => {
|
||||
expect(context.messages.filter((message) => message.role === "system")).toHaveLength(2);
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
agent.state.tools = [tool];
|
||||
await agent.prompt([
|
||||
{ role: "system", content: "", sections: { skills: "<skills>x</skills>" }, timestamp: 1 },
|
||||
{ role: "user", content: "hi", timestamp: 2 },
|
||||
]);
|
||||
|
||||
expect(agent.state.messages[1]).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { skills: "<skills>x</skills>" },
|
||||
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
|
||||
timestamp: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites pending tool declarations to match the executable set", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: { systemPrompt: "You are helpful.", tools: [createTool("first")] },
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
|
||||
// The pending message claims to add `second` and remove `first`, but the executable
|
||||
// set still has `first` and lacks `second`: the executable set wins.
|
||||
await agent.prompt([
|
||||
{
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { note: "<note>x</note>" },
|
||||
toolsAdded: [toToolDeclaration(createTool("second"))],
|
||||
toolsRemoved: [{ name: "first" }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{ role: "user", content: "hi", timestamp: 2 },
|
||||
]);
|
||||
|
||||
expect(agent.state.messages[1]).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { note: "<note>x</note>" },
|
||||
timestamp: 1,
|
||||
});
|
||||
expect(getCurrentSystemMessage(agent.state.messages)?.toolsAdded?.map((tool) => tool.name)).toEqual(["first"]);
|
||||
});
|
||||
|
||||
it("restores the transcript baseline when reset", () => {
|
||||
const tool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo input",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are helpful.",
|
||||
tools: [tool],
|
||||
messages: [{ role: "user", content: "old", timestamp: 1 }],
|
||||
},
|
||||
streamFn: unusedStreamFunction,
|
||||
});
|
||||
|
||||
agent.reset();
|
||||
|
||||
expect(agent.state.messages).toHaveLength(1);
|
||||
const initial = agent.state.messages[0];
|
||||
expect(initial?.role).toBe("system");
|
||||
if (initial?.role !== "system") throw new Error("expected initial system message");
|
||||
expect(initial.content).toBe("You are helpful.");
|
||||
expect(initial.toolsAdded?.map((value) => value.name)).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
|
|
@ -146,13 +327,13 @@ describe("Agent", () => {
|
|||
expect(eventCount).toBe(0);
|
||||
|
||||
// State mutators don't emit events
|
||||
agent.state.systemPrompt = "Test prompt";
|
||||
agent.state.thinkingLevel = "low";
|
||||
expect(eventCount).toBe(0);
|
||||
expect(agent.state.systemPrompt).toBe("Test prompt");
|
||||
expect(agent.state.thinkingLevel).toBe("low");
|
||||
|
||||
// Unsubscribe should work
|
||||
unsubscribe();
|
||||
agent.state.systemPrompt = "Another prompt";
|
||||
agent.state.thinkingLevel = "high";
|
||||
expect(eventCount).toBe(0); // Should not increase
|
||||
});
|
||||
|
||||
|
|
@ -442,10 +623,6 @@ describe("Agent", () => {
|
|||
it("should update state with mutators", () => {
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
// Test setSystemPrompt
|
||||
agent.state.systemPrompt = "Custom prompt";
|
||||
expect(agent.state.systemPrompt).toBe("Custom prompt");
|
||||
|
||||
// Test setModel
|
||||
const newModel = getModel("google", "gemini-2.5-flash");
|
||||
agent.state.model = newModel;
|
||||
|
|
@ -779,7 +956,7 @@ describe("Agent", () => {
|
|||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(sawAbortSignal).toBe(true);
|
||||
expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
|
||||
expect(callbackContextRoles).toEqual(["system", "user", "assistant", "toolResult"]);
|
||||
});
|
||||
|
||||
it("forwards sessionId to streamFunction options", async () => {
|
||||
|
|
|
|||
|
|
@ -50,11 +50,12 @@ async function basicPrompt(model: Model<string>) {
|
|||
await agent.prompt("What is 2+2? Answer with just the number.");
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
expect(agent.state.messages[0].role).toBe("user");
|
||||
expect(agent.state.messages[1].role).toBe("assistant");
|
||||
expect(agent.state.messages.length).toBe(3);
|
||||
expect(agent.state.messages[0].role).toBe("system");
|
||||
expect(agent.state.messages[1].role).toBe("user");
|
||||
expect(agent.state.messages[2].role).toBe("assistant");
|
||||
|
||||
const assistantMessage = agent.state.messages[1];
|
||||
const assistantMessage = agent.state.messages[2];
|
||||
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(getTextContent(assistantMessage)).toContain("4");
|
||||
}
|
||||
|
|
@ -157,7 +158,7 @@ async function stateUpdates(model: Model<string>) {
|
|||
expect(events.indexOf("message_end")).toBeLessThan(events.lastIndexOf("agent_end"));
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
expect(agent.state.messages.length).toBe(3);
|
||||
}
|
||||
|
||||
async function multiTurnConversation(model: Model<string>) {
|
||||
|
|
@ -172,12 +173,12 @@ async function multiTurnConversation(model: Model<string>) {
|
|||
});
|
||||
|
||||
await agent.prompt("My name is Alice.");
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
expect(agent.state.messages.length).toBe(3);
|
||||
|
||||
await agent.prompt("What is my name?");
|
||||
expect(agent.state.messages.length).toBe(4);
|
||||
expect(agent.state.messages.length).toBe(5);
|
||||
|
||||
const lastMessage = agent.state.messages[3];
|
||||
const lastMessage = agent.state.messages[4];
|
||||
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(getTextContent(lastMessage).toLowerCase()).toContain("alice");
|
||||
}
|
||||
|
|
@ -255,7 +256,7 @@ describe("Agent integration with faux provider", () => {
|
|||
|
||||
await agent.prompt("What is 2+2?");
|
||||
|
||||
const assistantMessage = agent.state.messages[1];
|
||||
const assistantMessage = agent.state.messages[2];
|
||||
if (assistantMessage?.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(assistantMessage.content).toEqual([
|
||||
{ type: "thinking", thinking: "step by step" },
|
||||
|
|
|
|||
|
|
@ -516,7 +516,8 @@ describe("harness compaction", () => {
|
|||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([
|
||||
(context) => {
|
||||
const message = context.messages[0];
|
||||
// The transcript leads with the summarization system prompt; the request is the first user message.
|
||||
const message = context.messages.find((entry) => entry.role === "user");
|
||||
const content = message?.role === "user" ? message.content : [];
|
||||
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
fauxProvider,
|
||||
type Message,
|
||||
type Model,
|
||||
normalizeContext,
|
||||
type SimpleStreamOptions,
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
|
|
@ -219,7 +220,7 @@ describe("streamHarnessAssistant", () => {
|
|||
toProviderMessages,
|
||||
request: (context, options) => {
|
||||
seenContext = context.messages;
|
||||
return faux.provider.streamSimple(faux.getModel(), context, options);
|
||||
return faux.provider.streamSimple(faux.getModel(), normalizeContext(context), options);
|
||||
},
|
||||
observer: {
|
||||
start() {
|
||||
|
|
|
|||
|
|
@ -196,7 +196,6 @@ describe("tool execution primitives", () => {
|
|||
content: [{ type: "text", text: "original" }],
|
||||
details: { original: true },
|
||||
usage: originalUsage,
|
||||
addedToolNames: ["new-tool"],
|
||||
};
|
||||
|
||||
const finalized = finalizeToolCall(
|
||||
|
|
@ -218,7 +217,6 @@ describe("tool execution primitives", () => {
|
|||
content: [{ type: "text", text: "patched" }],
|
||||
details: { patched: true },
|
||||
usage: replacementUsage,
|
||||
addedToolNames: ["new-tool"],
|
||||
terminate: true,
|
||||
});
|
||||
expect(message).toMatchObject({
|
||||
|
|
@ -228,7 +226,6 @@ describe("tool execution primitives", () => {
|
|||
content: [{ type: "text", text: "patched" }],
|
||||
details: { patched: true },
|
||||
usage: replacementUsage,
|
||||
addedToolNames: ["new-tool"],
|
||||
isError: false,
|
||||
});
|
||||
expect(message.timestamp).toBeGreaterThanOrEqual(before);
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ function tool(
|
|||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { value?: string };
|
||||
usage?: ToolResultMessage["usage"];
|
||||
addedToolNames?: string[];
|
||||
terminate?: boolean;
|
||||
}>,
|
||||
replay: "never" | "safe" = "never",
|
||||
|
|
@ -301,7 +300,6 @@ describe("durable tool batch", () => {
|
|||
const second = tool("second", async (value) => ({
|
||||
content: [{ type: "text", text: value }],
|
||||
details: { value },
|
||||
addedToolNames: ["introduced"],
|
||||
}));
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "first" }, { name: "second" }],
|
||||
|
|
@ -340,7 +338,7 @@ describe("durable tool batch", () => {
|
|||
at: "checkpoint",
|
||||
continuation: { kind: "need_assistant" },
|
||||
});
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second", "introduced"]);
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second"]);
|
||||
expect(
|
||||
await fixture.session.scanValues(
|
||||
storedValues.operationToolArgsPrefix(fixture.operationId),
|
||||
|
|
@ -539,7 +537,6 @@ describe("durable tool batch", () => {
|
|||
toolCallId: "call-0",
|
||||
toolName: "ready",
|
||||
content: [{ type: "text", text: "already done" }],
|
||||
addedToolNames: ["later"],
|
||||
isError: false,
|
||||
timestamp: 30,
|
||||
};
|
||||
|
|
@ -563,7 +560,7 @@ describe("durable tool batch", () => {
|
|||
at: "checkpoint",
|
||||
continuation: { kind: "may_finish", includeFinalAssistant: false },
|
||||
});
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready", "later"]);
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready"]);
|
||||
expect(fixture.events[0]).toMatchObject({ type: "turn_start", turnId: "turn-1", recovery: true });
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "turn_end", turnId: "turn-1", recovery: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -389,7 +389,6 @@ describe("runtime lane watch", () => {
|
|||
content: [{ type: "text", text: "settled" }],
|
||||
details: { kind: "real" },
|
||||
usage: toolUsage,
|
||||
addedToolNames: ["later"],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
|
|
@ -438,7 +437,6 @@ describe("runtime lane watch", () => {
|
|||
content: [{ type: "text", text: "settled" }],
|
||||
details: { kind: "real" },
|
||||
usage: toolUsage,
|
||||
addedToolNames: ["later"],
|
||||
terminate: true,
|
||||
},
|
||||
isError: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AssistantMessage, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
|
||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type ProxyAssistantMessageEvent, streamProxy } from "../src/proxy.ts";
|
||||
|
||||
|
|
@ -53,14 +54,10 @@ describe("streamProxy", () => {
|
|||
vi.fn(async () => new Response(body, { status: 200 })),
|
||||
);
|
||||
|
||||
const stream = streamProxy(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
},
|
||||
);
|
||||
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
});
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
const result = await stream.result();
|
||||
|
|
@ -86,14 +83,10 @@ describe("streamProxy", () => {
|
|||
vi.fn(async () => new Response(start + done, { status: 200 })),
|
||||
);
|
||||
|
||||
const stream = streamProxy(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
},
|
||||
);
|
||||
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
});
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
const result = await stream.result();
|
||||
|
|
@ -110,14 +103,10 @@ describe("streamProxy", () => {
|
|||
vi.fn(async () => new Response(body, { status: 200 })),
|
||||
);
|
||||
|
||||
const stream = streamProxy(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
},
|
||||
);
|
||||
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
||||
authToken: "test-token",
|
||||
proxyUrl: "https://proxy.example.com",
|
||||
});
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
const result = await stream.result();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
- Removed GPT-5.4 and GPT-5.4 mini from the OpenAI Codex catalog after they became unavailable to ChatGPT accounts ([#9394](https://github.com/earendil-works/pi/issues/9394)).
|
||||
- Fixed Mistral-hosted GLM-5.2 reasoning requests to use `reasoning_effort` instead of the ignored `prompt_mode` ([#9375](https://github.com/earendil-works/pi/issues/9375)).
|
||||
- Fixed OpenAI-compatible Responses errors to identify the actual provider instead of always labeling them as OpenAI errors ([#9298](https://github.com/earendil-works/pi/issues/9298)).
|
||||
- Fixed Baseten requests to send session-affinity headers from `sessionId` for automatic prompt-cache routing ([#9629](https://github.com/earendil-works/pi/issues/9629)).
|
||||
|
||||
## [0.85.1] - 2026-09-05
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
|||
- [OpenAI Compatibility Settings](#openai-compatibility-settings)
|
||||
- [Faux Provider for Tests](#faux-provider-for-tests)
|
||||
- [Cross-Provider Handoffs](#cross-provider-handoffs)
|
||||
- [System Messages](#system-messages)
|
||||
- [Context Serialization](#context-serialization)
|
||||
- [Browser Usage](#browser-usage)
|
||||
- [Bundling and Tree Shaking](#bundling-and-tree-shaking)
|
||||
|
|
@ -1168,12 +1169,13 @@ const ollamaReasoningModel: Model<'openai-completions'> = {
|
|||
|
||||
### Calling API Implementations Directly
|
||||
|
||||
The API implementations are importable on their own. Each module exports exactly `stream` and `streamSimple` with that API's full option typing. Direct calls bypass provider auth — pass `apiKey` explicitly:
|
||||
The API implementations are importable on their own. Each module exports exactly `stream` and `streamSimple` with that API's full option typing. Direct calls bypass provider auth and context normalization — pass `apiKey` explicitly and wrap the context in `normalizeContext()`:
|
||||
|
||||
```typescript
|
||||
import { normalizeContext } from '@earendil-works/pi-ai';
|
||||
import { stream } from '@earendil-works/pi-ai/api/anthropic-messages';
|
||||
|
||||
const s = stream(claudeModel, context, {
|
||||
const s = stream(claudeModel, normalizeContext(context), {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
thinkingEnabled: true,
|
||||
thinkingBudgetTokens: 2048,
|
||||
|
|
@ -1208,6 +1210,8 @@ interface OpenAICompletionsCompat {
|
|||
supportsUsageInStreaming?: boolean; // Whether provider supports `stream_options: { include_usage: true }` (default: true)
|
||||
supportsStrictMode?: boolean; // Whether provider supports `strict` in tool definitions (default: true)
|
||||
supportsOpenAIGrammarTools?: boolean; // Whether to emit OpenAI custom Lark/regex grammar tools; false falls back to normal function tools (default: false; the generated catalog enables it for capable models)
|
||||
supportsMidConvoSystemMessages?: boolean; // Whether the model accepts system messages after the conversation started; false folds them into the leading prompt (default: false; the generated catalog enables it for verified models)
|
||||
supportsMidConvoToolAdditions?: boolean; // Whether system messages can add tools mid-conversation via Kimi-style `tools` system messages; requires supportsMidConvoSystemMessages (default: false)
|
||||
sendSessionAffinityHeaders?: boolean; // Send session-affinity data from `sessionId` (default: true for OpenRouter, false otherwise)
|
||||
sessionAffinityFormat?: 'openai' | 'openai-nosession' | 'openrouter'; // Format for session affinity: 'openai' uses `prompt_cache_key`, `session_id`, `x-client-request-id`, and `x-session-affinity`; 'openai-nosession' uses `prompt_cache_key`, `x-client-request-id`, and `x-session-affinity`; 'openrouter' uses `x-session-id` (default: auto-detected)
|
||||
maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
|
||||
|
|
@ -1371,6 +1375,39 @@ const geminiResponse = await models.complete(gemini, context);
|
|||
|
||||
All providers can handle messages from other providers — text, tool calls and results (including images), thinking blocks (transformed to tagged text), and aborted messages with partial content. This enables flexible workflows: start with a fast model, switch to a more capable one for complex reasoning, or maintain continuity across provider outages.
|
||||
|
||||
## System Messages
|
||||
|
||||
`Context.systemPrompt` and `Context.tools` are shorthand for a leading system message. The public entry points (`Models.stream()`, `streamSimple()`, `complete()`, `completeSimple()`) accept a `Context` and call `normalizeContext()` once; everything below them, including `Provider.stream()`, `ProviderStreams`, and the API implementation modules, receives the resulting `TranscriptContext`, which only has `messages`. The transcript can also carry system messages later in the conversation to change the prompt or the tool set without rewriting the history:
|
||||
|
||||
```typescript
|
||||
interface SystemMessage {
|
||||
role: "system";
|
||||
content: string | TextContent[]; // leading: base prompt; later: added instructions
|
||||
sections?: Record<string, string | null>; // named prompt sections; later messages patch by name, null removes
|
||||
toolsAdded?: Tool[]; // tools that become available here
|
||||
toolsRemoved?: ToolReference[]; // tools that stop being available here
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
Sections are opaque text rendered verbatim after `content`, joined by blank lines. Keep each one self-delimiting (a tag, a heading) so the model can relate an update to the original. Replaying every system message in order yields the current prompt and tools; the replay helpers take the message list:
|
||||
|
||||
```typescript
|
||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai";
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "system", content: "You are helpful.", sections: { rules: "<rules>Be brief.</rules>" }, toolsAdded: [readTool], timestamp: 1 },
|
||||
{ role: "user", content: "hi", timestamp: 2 },
|
||||
{ role: "system", content: "", sections: { rules: "<rules>Be thorough.</rules>" }, toolsRemoved: [{ name: "read" }], timestamp: 3 },
|
||||
];
|
||||
getCurrentSystemPrompt(messages); // "You are helpful.\n\n<rules>Be thorough.</rules>"
|
||||
getCurrentTools(messages); // []
|
||||
```
|
||||
|
||||
A custom `Provider` or `ProviderStreams` implementation reads the prompt and tools the same way from `context.messages`; `context.systemPrompt` and `context.tools` do not exist at that layer.
|
||||
|
||||
Models that accept system messages mid-conversation (`supportsMidConvoSystemMessages` in the model's compat settings, set by the generated catalog for verified models) receive each later system message in place, so the cached prefix stays intact; section changes are framed by name for the model. Every other model receives `collapseSystemMessages(transcript)`: the replayed prompt and current tools as the leading system message, with later system messages dropped. Anthropic models that also set `supportsMidConvoToolChanges` send tool changes as native `tool_addition`/`tool_removal` blocks: the initial tools stay active at the top level, every later declaration is sent with `defer_loading` (plus a stable deferred placeholder from the first request, which keeps Anthropic's deferred-tool scaffolding in the cached prefix), and removed tools stay declared, so tool changes do not invalidate the prompt cache. That needs at least one initial tool and no same-name redefinition; otherwise the current tool list is sent at the top level with the system text only. OpenAI Responses models with `supportsAdditionalTools` or `supportsToolSearch` anchor additive tool changes at their message; everything else sends the current tool list at the top level.
|
||||
|
||||
## Context Serialization
|
||||
|
||||
The `Context` object can be easily serialized and deserialized using standard JSON methods, making it simple to persist conversations, implement chat history, or transfer contexts between services:
|
||||
|
|
@ -1638,7 +1675,7 @@ Adding a new LLM provider requires changes across multiple files. The layered la
|
|||
Create a new API implementation file (for example `bedrock-converse-stream.ts`) that exports exactly `stream` and `streamSimple`, plus:
|
||||
|
||||
- An options interface extending `StreamOptions` (for example `BedrockOptions`)
|
||||
- Message conversion functions to transform `Context` to provider format
|
||||
- Message conversion functions to transform the `TranscriptContext` messages to provider format; read the prompt and tools from the transcript with `getInitialSystemMessage()`, `getCurrentTools()`, and `resolveTranscript()`
|
||||
- Tool conversion if the provider supports tools
|
||||
- Response parsing to emit standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
|
||||
|
||||
|
|
|
|||
|
|
@ -366,11 +366,8 @@ const OPENAI_TOOL_SEARCH_MODEL_IDS = new Set([
|
|||
"gpt-5.6-luna",
|
||||
"gpt-6-astra",
|
||||
]);
|
||||
// Public OpenAI documents additional_tools for applications that load tools
|
||||
// outside the normal tool-search flow. Codex currently uses the input item for
|
||||
// its Responses Lite GPT-5.6 models.
|
||||
// https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input
|
||||
const OPENAI_ADDITIONAL_TOOLS_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS;
|
||||
const OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS = OPENAI_TOOL_SEARCH_MODEL_IDS;
|
||||
const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set([
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
|
|
@ -558,11 +555,11 @@ function supportsOpenAiMax(model: Model<Api>): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function isGoogleThinkingApi(model: Model<any>): boolean {
|
||||
return model.api === "google-generative-ai" || model.api === "google-vertex";
|
||||
}
|
||||
|
||||
const VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS = new Set(["anthropic", "openrouter"]);
|
||||
// OpenRouter rejects `configuration_update` system messages on Opus 5 ("Mid-conversation
|
||||
// reasoning effort (configuration_update) is not supported on anthropic/claude-opus-5-20260723")
|
||||
// while accepting them on Fable 5.1, so gate that model there.
|
||||
const MID_CONVO_EFFORT_UNSUPPORTED_ANTHROPIC_MODELS = new Set(["openrouter:anthropic/claude-opus-5"]);
|
||||
|
||||
function supportsAnthropicMidConvoEffort(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase().replace(/^~?anthropic\//, "");
|
||||
|
|
@ -572,6 +569,13 @@ function supportsAnthropicMidConvoEffort(modelId: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function supportsAnthropicMidConvoSystemMessages(modelId: string): boolean {
|
||||
return (
|
||||
/^claude-opus-(?:4[.-]8|5)(?:-\d{8})?$/.test(modelId) ||
|
||||
/^claude-(?:fable|mythos)-5(?:[.-]1)?(?:-\d{8})?$/.test(modelId)
|
||||
);
|
||||
}
|
||||
|
||||
function isAnthropicAdaptiveThinkingModel(modelId: string): boolean {
|
||||
return (
|
||||
modelId.includes("opus-4-6") ||
|
||||
|
|
@ -622,16 +626,17 @@ const OPENAI_COMPLETIONS_DEFAULT_COMPAT = {
|
|||
zaiToolStream: false,
|
||||
supportsStrictMode: true,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
supportsMidConvoSystemMessages: false,
|
||||
supportsMidConvoToolAdditions: false,
|
||||
sendSessionAffinityHeaders: false,
|
||||
supportsLongCacheRetention: true,
|
||||
} satisfies Required<
|
||||
Omit<
|
||||
OpenAICompletionsCompat,
|
||||
"cacheControlFormat" | "deferredToolsMode" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField"
|
||||
"cacheControlFormat" | "supportsThinkingTokenBudget" | "thinkingTokenBudgetField"
|
||||
>
|
||||
> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
};
|
||||
|
||||
type OpenAICompletionsResolvedCompat = typeof OPENAI_COMPLETIONS_DEFAULT_COMPAT & {
|
||||
|
|
@ -725,6 +730,8 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
|
|||
zaiToolStream: false,
|
||||
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
supportsMidConvoSystemMessages: false,
|
||||
supportsMidConvoToolAdditions: false,
|
||||
...(cacheControlFormat ? { cacheControlFormat } : {}),
|
||||
sendSessionAffinityHeaders: isOpenRouter,
|
||||
supportsLongCacheRetention: !(
|
||||
|
|
@ -854,6 +861,59 @@ function applyOpenAIToolSearchMetadata(model: Model<Api>): void {
|
|||
};
|
||||
}
|
||||
|
||||
// Moonshot Kimi K2.6/K2.7 accept system text after the conversation starts but reject
|
||||
// tool-bearing system messages. Kimi K3 accepts both forms; Fireworks and OpenCode pass
|
||||
// its tool-bearing form through. GitHub Copilot forwards K3 text but silently drops its
|
||||
// tool-bearing message. DeepSeek V4 Pro and OpenAI models behind OpenRouter also accept
|
||||
// plain system text in place.
|
||||
function applyOpenAICompletionsTranscriptMetadata(model: Model<Api>): void {
|
||||
if (model.api !== "openai-completions") return;
|
||||
const isKimiK3 =
|
||||
(model.provider.startsWith("moonshot") && model.id === "kimi-k3") ||
|
||||
(model.provider === "fireworks" && model.id.includes("kimi-k3")) ||
|
||||
((model.provider === "opencode" || model.provider === "opencode-go") && model.id === "kimi-k3");
|
||||
const isMoonshotKimiK2 =
|
||||
model.provider.startsWith("moonshot") &&
|
||||
(model.id === "kimi-k2.6" || model.id === "kimi-k2.7-code" || model.id === "kimi-k2.7-code-highspeed");
|
||||
const isTextOnly =
|
||||
isMoonshotKimiK2 ||
|
||||
(model.provider === "github-copilot" && model.id === "kimi-k3") ||
|
||||
(model.provider === "deepseek" && model.id === "deepseek-v4-pro") ||
|
||||
(model.provider === "openrouter" &&
|
||||
model.id.startsWith("openai/") &&
|
||||
OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS.has(model.id.slice("openai/".length)));
|
||||
if (!isKimiK3 && !isTextOnly) return;
|
||||
model.compat = {
|
||||
...(model.compat as OpenAICompletionsCompat | undefined),
|
||||
supportsMidConvoSystemMessages: true,
|
||||
...(isKimiK3 ? { supportsMidConvoToolAdditions: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Newer OpenAI Responses models accept developer messages after the conversation has started.
|
||||
// OpenCode Zen, OpenCode Go, and GitHub Copilot pass both those messages and
|
||||
// `additional_tools` items through to OpenAI unchanged; tool search is not verified
|
||||
// through those proxies.
|
||||
const OPENAI_RESPONSES_PROXY_PROVIDERS = new Set(["opencode", "opencode-go", "github-copilot"]);
|
||||
|
||||
function applyOpenAIResponsesTranscriptMetadata(model: Model<Api>): void {
|
||||
const isOpenAIResponses = model.provider === "openai" && model.api === "openai-responses";
|
||||
const isOpenAICodex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
|
||||
const isProxiedResponses =
|
||||
OPENAI_RESPONSES_PROXY_PROVIDERS.has(model.provider) && model.api === "openai-responses";
|
||||
if (
|
||||
!(isOpenAIResponses || isOpenAICodex || isProxiedResponses) ||
|
||||
!OPENAI_MID_CONVO_SYSTEM_MESSAGE_MODEL_IDS.has(model.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
model.compat = {
|
||||
...(model.compat as OpenAIResponsesCompat | undefined),
|
||||
supportsMidConvoSystemMessages: true,
|
||||
...(isProxiedResponses ? { supportsAdditionalTools: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI charges prompt-cache writes starting with the GPT-5.6 family, and exactly
|
||||
// those models accept `prompt_cache_options`; older models reject the parameter.
|
||||
// https://developers.openai.com/api/docs/guides/prompt-caching
|
||||
|
|
@ -866,19 +926,22 @@ function applyOpenAIExplicitPromptCacheMetadata(model: Model<Api>): void {
|
|||
};
|
||||
}
|
||||
|
||||
function isGemini3ProModel(modelId: string): boolean {
|
||||
return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase());
|
||||
}
|
||||
|
||||
function isGemini3FlashModel(modelId: string): boolean {
|
||||
const id = modelId.toLowerCase();
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
|
||||
}
|
||||
|
||||
function isGemma4Model(modelId: string): boolean {
|
||||
return /gemma-?4/.test(modelId.toLowerCase());
|
||||
}
|
||||
|
||||
function getGoogleThinkingLevelMap(
|
||||
modelId: string,
|
||||
reasoningOptions: readonly ModelsDevReasoningOption[],
|
||||
): NonNullable<Model<Api>["thinkingLevelMap"]> | undefined {
|
||||
const effortMap = getEffortThinkingLevelMap(reasoningOptions);
|
||||
if (effortMap) return effortMap;
|
||||
if (isGemma4Model(modelId)) {
|
||||
return { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applyThinkingLevelMetadata(model: Model<any>): void {
|
||||
if (
|
||||
(model.api === "openai-responses" || model.api === "azure-openai-responses") &&
|
||||
|
|
@ -972,15 +1035,6 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
|
|||
: DEEPSEEK_V4_THINKING_LEVEL_MAP,
|
||||
);
|
||||
}
|
||||
if (isGoogleThinkingApi(model) && isGemini3ProModel(model.id)) {
|
||||
mergeThinkingLevelMap(model, { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" });
|
||||
}
|
||||
if (isGoogleThinkingApi(model) && isGemini3FlashModel(model.id)) {
|
||||
mergeThinkingLevelMap(model, { off: null });
|
||||
}
|
||||
if (isGoogleThinkingApi(model) && isGemma4Model(model.id)) {
|
||||
mergeThinkingLevelMap(model, { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" });
|
||||
}
|
||||
if (model.provider === "groq" && model.id === "qwen/qwen3.6-27b") {
|
||||
mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null, high: "default" });
|
||||
}
|
||||
|
|
@ -1064,10 +1118,20 @@ function getAnthropicMessagesCompat(provider: string, modelId: string): Anthropi
|
|||
const compat: AnthropicMessagesCompat = {};
|
||||
if (
|
||||
VERIFIED_ANTHROPIC_MID_CONVO_EFFORT_PROVIDERS.has(provider) &&
|
||||
supportsAnthropicMidConvoEffort(modelId)
|
||||
supportsAnthropicMidConvoEffort(modelId) &&
|
||||
!MID_CONVO_EFFORT_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)
|
||||
) {
|
||||
compat.supportsMidConvoEffort = true;
|
||||
}
|
||||
if (provider === "anthropic" && supportsAnthropicMidConvoSystemMessages(modelId)) {
|
||||
compat.supportsMidConvoSystemMessages = true;
|
||||
compat.supportsMidConvoToolChanges = true;
|
||||
}
|
||||
// OpenCode Zen and GitHub Copilot forward mid-conversation system messages but reject
|
||||
// `tool_addition`/`tool_removal` blocks, so tool changes stay top-level there.
|
||||
if ((provider === "opencode" || provider === "github-copilot") && supportsAnthropicMidConvoSystemMessages(modelId)) {
|
||||
compat.supportsMidConvoSystemMessages = true;
|
||||
}
|
||||
if (EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS.has(`${provider}:${modelId}`)) {
|
||||
compat.supportsEagerToolInputStreaming = false;
|
||||
}
|
||||
|
|
@ -1333,6 +1397,10 @@ function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Ap
|
|||
supportsUsageInStreaming: true,
|
||||
maxTokensField: "max_tokens",
|
||||
supportsStrictMode: true,
|
||||
// Baseten automatic prompt caching needs session affinity so related
|
||||
// requests land on the same replica. See:
|
||||
// https://docs.baseten.co/inference/model-apis/pricing-and-limits
|
||||
sendSessionAffinityHeaders: true,
|
||||
supportsLongCacheRetention: false,
|
||||
};
|
||||
const reasoningEffortCompat: OpenAICompletionsCompat = {
|
||||
|
|
@ -1418,12 +1486,88 @@ function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Ap
|
|||
return models;
|
||||
}
|
||||
|
||||
function processGoogleModels(data: ModelsDevCatalog): Model<Api>[] {
|
||||
const models: Model<Api>[] = [];
|
||||
const googleModels = data.google?.models;
|
||||
if (googleModels) {
|
||||
for (const [modelId, model] of Object.entries(googleModels)) {
|
||||
if (model.tool_call !== true) continue;
|
||||
const source =
|
||||
modelId === "gemini-flash-latest"
|
||||
? (googleModels["gemini-3.5-flash"] ?? model)
|
||||
: modelId === "gemini-flash-lite-latest"
|
||||
? (googleModels["gemini-3.1-flash-lite"] ?? model)
|
||||
: model;
|
||||
const thinkingLevelMap = getGoogleThinkingLevelMap(modelId, source.reasoning_options ?? []);
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: model.name || modelId,
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
reasoning: source.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: source.cost?.input || 0,
|
||||
output: source.cost?.output || 0,
|
||||
cacheRead: source.cost?.cache_read || 0,
|
||||
cacheWrite: source.cost?.cache_write || 0,
|
||||
},
|
||||
contextWindow: source.limit?.context || 4096,
|
||||
maxTokens: source.limit?.output || 4096,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The google-vertex models.dev catalog also includes Claude, OpenAI, and other
|
||||
// MaaS models that do not use the @google/genai Gemini streaming path.
|
||||
const vertexModels = data["google-vertex"]?.models;
|
||||
if (vertexModels) {
|
||||
for (const [modelId, model] of Object.entries(vertexModels)) {
|
||||
if (model.tool_call !== true || !modelId.startsWith("gemini-")) continue;
|
||||
if (modelId === "gemini-3.1-flash-lite-preview") continue;
|
||||
const source =
|
||||
modelId === "gemini-flash-latest"
|
||||
? (vertexModels["gemini-3.5-flash"] ?? model)
|
||||
: modelId === "gemini-flash-lite-latest"
|
||||
? (vertexModels["gemini-3.1-flash-lite"] ?? model)
|
||||
: model;
|
||||
const thinkingLevelMap = getGoogleThinkingLevelMap(modelId, source.reasoning_options ?? []);
|
||||
// models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that
|
||||
// do not match the official Gemini API standard pricing table. pi only accounts
|
||||
// cachedContentTokenCount as cacheRead.
|
||||
const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0;
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: model.name || modelId,
|
||||
api: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
baseUrl: VERTEX_BASE_URL,
|
||||
reasoning: source.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: source.cost?.input || 0,
|
||||
output: source.cost?.output || 0,
|
||||
cacheRead,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: source.limit?.context || 4096,
|
||||
maxTokens: source.limit?.output || 4096,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function processFireworksModels(provider: ModelsDevProvider | undefined): Model<Api>[] {
|
||||
if (!provider?.models) return [];
|
||||
|
||||
const anthropicCompat: AnthropicMessagesCompat = {
|
||||
// Arbitrary loader names work, but Fireworks only defers the prefix for ToolSearch/tool_search.
|
||||
supportsToolReferences: true,
|
||||
allowEmptySignature: true,
|
||||
sendSessionAffinityHeaders: true,
|
||||
supportsEagerToolInputStreaming: false,
|
||||
|
|
@ -1440,7 +1584,6 @@ function processFireworksModels(provider: ModelsDevProvider | undefined): Model<
|
|||
...openAICompat,
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
thinkingFormat: "openai",
|
||||
deferredToolsMode: "kimi",
|
||||
};
|
||||
const models: Model<Api>[] = [];
|
||||
|
||||
|
|
@ -1585,81 +1728,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// Process Google models
|
||||
if (data.google?.models) {
|
||||
for (const [modelId, model] of Object.entries(data.google.models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
let source = m;
|
||||
if (modelId === "gemini-flash-latest") {
|
||||
source = (data.google.models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m;
|
||||
}
|
||||
if (modelId === "gemini-flash-lite-latest") {
|
||||
source = (data.google.models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m;
|
||||
}
|
||||
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
reasoning: source.reasoning === true,
|
||||
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: source.cost?.input || 0,
|
||||
output: source.cost?.output || 0,
|
||||
cacheRead: source.cost?.cache_read || 0,
|
||||
cacheWrite: source.cost?.cache_write || 0,
|
||||
},
|
||||
contextWindow: source.limit?.context || 4096,
|
||||
maxTokens: source.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("google", modelId, source);
|
||||
}
|
||||
}
|
||||
|
||||
// Process Google Vertex Gemini models. The google-vertex models.dev catalog also includes
|
||||
// Claude, OpenAI, and other MaaS models that do not use the @google/genai Gemini streaming
|
||||
// path implemented by our google-vertex provider.
|
||||
if (data["google-vertex"]?.models) {
|
||||
for (const [modelId, model] of Object.entries(data["google-vertex"].models)) {
|
||||
const m = model as ModelsDevModel;
|
||||
if (m.tool_call !== true) continue;
|
||||
if (!modelId.startsWith("gemini-")) continue;
|
||||
if (modelId === "gemini-3.1-flash-lite-preview") continue;
|
||||
let source = m;
|
||||
if (modelId === "gemini-flash-latest") {
|
||||
source = (data["google-vertex"].models["gemini-3.5-flash"] as ModelsDevModel | undefined) ?? m;
|
||||
}
|
||||
if (modelId === "gemini-flash-lite-latest") {
|
||||
source = (data["google-vertex"].models["gemini-3.1-flash-lite"] as ModelsDevModel | undefined) ?? m;
|
||||
}
|
||||
|
||||
// models.dev reports Vertex cache_read/cache_write values for Gemini 2.5 Flash that
|
||||
// do not match the official Gemini API standard pricing table. pi only accounts
|
||||
// cachedContentTokenCount as cacheRead.
|
||||
const cacheRead = modelId === "gemini-2.5-flash" ? 0.03 : source.cost?.cache_read || 0;
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
api: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
baseUrl: VERTEX_BASE_URL,
|
||||
reasoning: source.reasoning === true,
|
||||
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: source.cost?.input || 0,
|
||||
output: source.cost?.output || 0,
|
||||
cacheRead,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: source.limit?.context || 4096,
|
||||
maxTokens: source.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("google-vertex", modelId, source);
|
||||
}
|
||||
}
|
||||
models.push(...processGoogleModels(data));
|
||||
|
||||
// Process OpenAI models
|
||||
if (data.openai?.models) {
|
||||
|
|
@ -2111,6 +2180,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
const thinkingLevelMap =
|
||||
api === "google-generative-ai"
|
||||
? getGoogleThinkingLevelMap(modelId, m.reasoning_options ?? [])
|
||||
: undefined;
|
||||
models.push({
|
||||
id: modelId,
|
||||
name: m.name || modelId,
|
||||
|
|
@ -2118,6 +2191,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
provider: variant.provider,
|
||||
baseUrl,
|
||||
reasoning: m.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
|
|
@ -2298,7 +2372,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
const compat = isKimiK3 ? { ...moonshotCompat } : moonshotCompat;
|
||||
if (isKimiK3) {
|
||||
compat.requiresReasoningContentOnAssistantMessages = true;
|
||||
compat.deferredToolsMode = "kimi";
|
||||
compat.thinkingFormat = "openai";
|
||||
compat.supportsReasoningEffort = true;
|
||||
}
|
||||
|
|
@ -2950,6 +3023,8 @@ async function generateModels() {
|
|||
applyStrictToolCompatMetadata(model);
|
||||
applyOpenAIGrammarToolCompatMetadata(model);
|
||||
applyOpenAIToolSearchMetadata(model);
|
||||
applyOpenAICompletionsTranscriptMetadata(model);
|
||||
applyOpenAIResponsesTranscriptMetadata(model);
|
||||
applyOpenAIExplicitPromptCacheMetadata(model);
|
||||
}
|
||||
applyAnthropicAllowedFallbackModelMetadata(allModels.filter(isAnthropicFallbackMetadataModel));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import type {
|
|||
Api,
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Context,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
|
|
@ -31,7 +30,6 @@ import type {
|
|||
ToolCall,
|
||||
ToolResultMessage,
|
||||
} from "../types.ts";
|
||||
import { splitDeferredTools } from "../utils/deferred-tools.ts";
|
||||
import { appendAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
|
|
@ -40,6 +38,15 @@ import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
|||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import {
|
||||
getCurrentTools,
|
||||
getDeclaredTools,
|
||||
getInitialSystemMessage,
|
||||
hasToolRedefinitions,
|
||||
resolveTranscript,
|
||||
type TranscriptContext,
|
||||
} from "../utils/transcript.ts";
|
||||
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
|
|
@ -176,6 +183,21 @@ const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|||
const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
||||
const MID_CONVERSATION_OUTPUT_CONFIG_BETA = "mid-conversation-output-config-2026-07-01";
|
||||
const THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01";
|
||||
const MID_CONVERSATION_TOOL_CHANGES_BETA = "mid-conversation-tool-changes-2026-07-01";
|
||||
|
||||
/**
|
||||
* Stable deferred tool declared whenever native tool changes are in use. Anthropic adds
|
||||
* hidden prompt scaffolding as soon as any tool has `defer_loading`; declaring this
|
||||
* placeholder from the first request keeps that scaffolding in the cached prefix, so the
|
||||
* first real late tool does not invalidate the cache (measured: full miss without it).
|
||||
* It is never activated and the model cannot see it.
|
||||
*/
|
||||
const DEFERRED_TOOL_PLACEHOLDER: BetaTool = {
|
||||
name: "__pi_deferred_placeholder__",
|
||||
description: "Reserved placeholder. Never available. Never call this.",
|
||||
input_schema: { type: "object", properties: {}, required: [] },
|
||||
defer_loading: true,
|
||||
};
|
||||
|
||||
function shouldUseServerSideFallbackBeta(model: Model<"anthropic-messages">): boolean {
|
||||
return (model.compat?.allowedFallbackModels?.length ?? 0) > 0;
|
||||
|
|
@ -192,24 +214,11 @@ function getAnthropicCompat(model: Model<"anthropic-messages">) {
|
|||
supportsTemperature: model.compat?.supportsTemperature ?? true,
|
||||
allowEmptySignature: model.compat?.allowEmptySignature ?? false,
|
||||
supportsStrictTools: model.compat?.supportsStrictTools ?? false,
|
||||
supportsToolReferences: model.compat?.supportsToolReferences ?? defaultSupportsToolReferences(model),
|
||||
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
|
||||
supportsMidConvoToolChanges: model.compat?.supportsMidConvoToolChanges ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default for `supportsToolReferences`: first-party Anthropic models except
|
||||
* Haiku (rejects client-side tool_reference blocks) and models that predate
|
||||
* tool search (Claude 3.x, Opus/Sonnet 4.0, Opus 4.1).
|
||||
*/
|
||||
function defaultSupportsToolReferences(model: Model<"anthropic-messages">): boolean {
|
||||
if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
|
||||
const version = model.id.match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
|
||||
if (!version) return false;
|
||||
const major = Number(version[1]);
|
||||
const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
|
||||
return major > 4 || (major === 4 && minor >= 5);
|
||||
}
|
||||
|
||||
export interface AnthropicOptions extends StreamOptions {
|
||||
/**
|
||||
* Enable extended thinking.
|
||||
|
|
@ -501,10 +510,12 @@ async function* iterateAnthropicEvents(
|
|||
|
||||
export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: AnthropicOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, getAnthropicCompat(model).supportsMidConvoSystemMessages);
|
||||
const currentTools = getCurrentTools(normalizedContext.messages);
|
||||
|
||||
(async () => {
|
||||
const providerThinkingLevel = model.compat?.supportsMidConvoEffort ? (options?.effort ?? "high") : undefined;
|
||||
|
|
@ -542,9 +553,9 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
|||
|
||||
let copilotDynamicHeaders: Record<string, string> | undefined;
|
||||
if (model.provider === "github-copilot") {
|
||||
const hasImages = hasCopilotVisionInput(context.messages);
|
||||
const hasImages = hasCopilotVisionInput(normalizedContext.messages);
|
||||
copilotDynamicHeaders = buildCopilotDynamicHeaders({
|
||||
messages: context.messages,
|
||||
messages: normalizedContext.messages,
|
||||
hasImages,
|
||||
});
|
||||
}
|
||||
|
|
@ -563,7 +574,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
|||
client = created.client;
|
||||
isOAuth = created.isOAuthToken;
|
||||
}
|
||||
let params = buildParams(model, context, isOAuth, options);
|
||||
let params = buildParams(model, normalizedContext, isOAuth, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = { ...(nextParams as MessageCreateParamsStreaming), stream: true };
|
||||
|
|
@ -650,7 +661,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
|
|||
type: "toolCall",
|
||||
id: event.content_block.id,
|
||||
name: isOAuth
|
||||
? fromClaudeCodeName(event.content_block.name, context.tools)
|
||||
? fromClaudeCodeName(event.content_block.name, currentTools)
|
||||
: event.content_block.name,
|
||||
arguments: (event.content_block.input as Record<string, any>) ?? {},
|
||||
partialJson: "",
|
||||
|
|
@ -845,7 +856,7 @@ function mapThinkingLevelToEffort(
|
|||
|
||||
export const streamSimple: StreamFunction<"anthropic-messages", SimpleStreamOptions> = (
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
assertRequestAuth(model.provider, options?.apiKey, options?.headers);
|
||||
|
|
@ -978,8 +989,9 @@ function createClient(
|
|||
|
||||
function getBetaFeatures(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
isOAuthToken: boolean,
|
||||
nativeToolChanges: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): NonNullable<MessageCreateParamsStreaming["betas"]> {
|
||||
let configuredFeatures: string | null | undefined;
|
||||
|
|
@ -1015,42 +1027,41 @@ function getBetaFeatures(
|
|||
if (model.compat?.supportsMidConvoEffort === true) {
|
||||
features.push(MID_CONVERSATION_OUTPUT_CONFIG_BETA, THINKING_BINDING_CONTROLS_BETA);
|
||||
}
|
||||
if (nativeToolChanges) features.push(MID_CONVERSATION_TOOL_CHANGES_BETA);
|
||||
return [...new Set(features)];
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
isOAuthToken: boolean,
|
||||
options?: AnthropicOptions,
|
||||
): MessageCreateParamsStreaming {
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention, options?.env);
|
||||
const compat = getAnthropicCompat(model);
|
||||
const initialSystemMessage = getInitialSystemMessage(context.messages);
|
||||
const initialSystemText = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const normalizeToolName = isOAuthToken ? toClaudeCodeName : (name: string) => name;
|
||||
const toolPlacement = splitDeferredTools(
|
||||
{ ...context, messages: transformedMessages },
|
||||
compat.supportsToolReferences,
|
||||
normalizeToolName,
|
||||
);
|
||||
let immediateTools = toolPlacement.immediate;
|
||||
let deferredTools = [...toolPlacement.deferred.values()];
|
||||
if (immediateTools.length === 0 && deferredTools.length > 0) {
|
||||
immediateTools = deferredTools;
|
||||
deferredTools = [];
|
||||
}
|
||||
const deferredToolNames = new Set(deferredTools.map((tool) => normalizeToolName(tool.name)));
|
||||
const conversationMessages = initialSystemMessage ? transformedMessages.slice(1) : transformedMessages;
|
||||
// Native tool changes reference tools by name, so a redefined name cannot be expressed,
|
||||
// and Anthropic rejects a tool list where every tool is deferred, so there must be an
|
||||
// initial active tool to anchor the deferred ones. Otherwise the current tool list is sent.
|
||||
const initialTools = initialSystemMessage?.toolsAdded ?? [];
|
||||
const nativeToolChanges =
|
||||
compat.supportsMidConvoSystemMessages &&
|
||||
compat.supportsMidConvoToolChanges &&
|
||||
initialTools.length > 0 &&
|
||||
!hasToolRedefinitions(context.messages);
|
||||
const converted = convertMessages(
|
||||
transformedMessages,
|
||||
conversationMessages,
|
||||
isOAuthToken,
|
||||
cacheControl,
|
||||
compat.allowEmptySignature,
|
||||
deferredToolNames,
|
||||
normalizeToolName,
|
||||
model.compat?.supportsMidConvoEffort === true ? model.provider : undefined,
|
||||
nativeToolChanges,
|
||||
);
|
||||
const activeEffort = options?.effort ?? "high";
|
||||
const betaFeatures = getBetaFeatures(model, context, isOAuthToken, options);
|
||||
const betaFeatures = getBetaFeatures(model, context, isOAuthToken, nativeToolChanges, options);
|
||||
const params: MessageCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
messages:
|
||||
|
|
@ -1071,19 +1082,19 @@ function buildParams(
|
|||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
];
|
||||
if (context.systemPrompt) {
|
||||
if (initialSystemText) {
|
||||
params.system.push({
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
text: sanitizeSurrogates(initialSystemText),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
});
|
||||
}
|
||||
} else if (context.systemPrompt) {
|
||||
} else if (initialSystemText) {
|
||||
// Add cache control to system prompt for non-OAuth tokens
|
||||
params.system = [
|
||||
{
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(context.systemPrompt),
|
||||
text: sanitizeSurrogates(initialSystemText),
|
||||
...(cacheControl ? { cache_control: cacheControl } : {}),
|
||||
},
|
||||
];
|
||||
|
|
@ -1099,24 +1110,41 @@ function buildParams(
|
|||
params.temperature = options.temperature;
|
||||
}
|
||||
|
||||
if (immediateTools.length > 0 || deferredTools.length > 0) {
|
||||
const toolCacheControl = compat.supportsCacheControlOnTools ? cacheControl : undefined;
|
||||
if (nativeToolChanges) {
|
||||
// Initial tools stay active with the cache breakpoint on the last one. Every later
|
||||
// declaration is deferred and only surfaced by its `tool_addition` block; removed
|
||||
// tools stay declared and are withdrawn by `tool_removal`. The request-level list
|
||||
// therefore only grows, keeping the cached prefix intact across tool changes.
|
||||
const initialNames = new Set(initialTools.map((tool) => tool.name));
|
||||
const laterTools = getDeclaredTools(context.messages).filter((tool) => !initialNames.has(tool.name));
|
||||
params.tools = [
|
||||
...convertTools(
|
||||
immediateTools,
|
||||
initialTools,
|
||||
isOAuthToken,
|
||||
compat.supportsEagerToolInputStreaming,
|
||||
compat.supportsStrictTools,
|
||||
compat.supportsCacheControlOnTools ? cacheControl : undefined,
|
||||
toolCacheControl,
|
||||
),
|
||||
DEFERRED_TOOL_PLACEHOLDER,
|
||||
...convertTools(
|
||||
deferredTools,
|
||||
laterTools,
|
||||
isOAuthToken,
|
||||
compat.supportsEagerToolInputStreaming,
|
||||
compat.supportsStrictTools,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
).map((tool) => ({ ...tool, defer_loading: true })),
|
||||
];
|
||||
} else {
|
||||
const tools = getCurrentTools(context.messages);
|
||||
if (tools.length > 0) {
|
||||
params.tools = convertTools(
|
||||
tools,
|
||||
isOAuthToken,
|
||||
compat.supportsEagerToolInputStreaming,
|
||||
compat.supportsStrictTools,
|
||||
toolCacheControl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Managed effort models always use adaptive thinking so prefix mismatches can
|
||||
|
|
@ -1180,38 +1208,12 @@ function normalizeToolCallId(id: string): string {
|
|||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
||||
}
|
||||
|
||||
function convertToolResult(
|
||||
msg: ToolResultMessage,
|
||||
isOAuthToken: boolean,
|
||||
deferredToolNames: ReadonlySet<string>,
|
||||
loadedToolNames: Set<string>,
|
||||
normalizeToolName: (name: string) => string,
|
||||
): { toolResult: ContentBlockParam; siblingContent: ContentBlockParam[] } {
|
||||
const references: Array<{ type: "tool_reference"; tool_name: string }> = [];
|
||||
for (const name of msg.addedToolNames ?? []) {
|
||||
const normalizedName = normalizeToolName(name);
|
||||
if (!deferredToolNames.has(normalizedName) || loadedToolNames.has(normalizedName)) continue;
|
||||
loadedToolNames.add(normalizedName);
|
||||
references.push({
|
||||
type: "tool_reference",
|
||||
tool_name: isOAuthToken ? toClaudeCodeName(name) : name,
|
||||
});
|
||||
}
|
||||
const convertedContent = convertContentBlocks(msg.content);
|
||||
// Anthropic rejects tool references mixed with ordinary tool-result content.
|
||||
function convertToolResult(msg: ToolResultMessage): ContentBlockParam {
|
||||
return {
|
||||
toolResult: {
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.toolCallId,
|
||||
content: references.length > 0 ? references : convertedContent,
|
||||
is_error: msg.isError,
|
||||
},
|
||||
siblingContent:
|
||||
references.length === 0
|
||||
? []
|
||||
: typeof convertedContent === "string"
|
||||
? [{ type: "text", text: convertedContent }]
|
||||
: convertedContent,
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.toolCallId,
|
||||
content: convertContentBlocks(msg.content),
|
||||
is_error: msg.isError,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1225,18 +1227,47 @@ function convertMessages(
|
|||
isOAuthToken: boolean,
|
||||
cacheControl?: CacheControlEphemeral,
|
||||
allowEmptySignature = false,
|
||||
deferredToolNames: ReadonlySet<string> = new Set(),
|
||||
normalizeToolName: (name: string) => string = (name) => name,
|
||||
managedProvider?: string,
|
||||
nativeToolChanges = false,
|
||||
): ConvertedAnthropicMessages {
|
||||
const params: MessageParam[] = [];
|
||||
const assistantLevels = new Map<number, AnthropicEffort>();
|
||||
const loadedToolNames = new Set<string>();
|
||||
// Later system messages are held back and emitted directly before the next assistant
|
||||
// message (or at the end of the transcript). Anthropic requires `tool_result` blocks to
|
||||
// immediately follow their `tool_use`, so a system message between them is rejected; this
|
||||
// also mirrors where the managed-effort system messages are inserted. As a result an
|
||||
// update placed before a user message in the transcript lands after it on the wire.
|
||||
const pendingSystemMessages: MessageParam[] = [];
|
||||
const flushPendingSystemMessages = (): void => {
|
||||
params.push(...pendingSystemMessages);
|
||||
pendingSystemMessages.length = 0;
|
||||
};
|
||||
|
||||
for (let i = 0; i < transformedMessages.length; i++) {
|
||||
const msg = transformedMessages[i];
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "system") {
|
||||
// Later system messages only reach this point when the model accepts them natively;
|
||||
// otherwise the transcript was collapsed into the leading message before conversion.
|
||||
const text = renderSystemMessageUpdate(msg);
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
if (text.length > 0) blocks.push({ type: "text", text: sanitizeSurrogates(text) });
|
||||
if (nativeToolChanges) {
|
||||
for (const tool of msg.toolsRemoved ?? []) {
|
||||
blocks.push({
|
||||
type: "tool_removal",
|
||||
tool: { type: "tool_reference", name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name },
|
||||
});
|
||||
}
|
||||
for (const tool of msg.toolsAdded ?? []) {
|
||||
blocks.push({
|
||||
type: "tool_addition",
|
||||
tool: { type: "tool_reference", name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blocks.length > 0) pendingSystemMessages.push({ role: "system", content: blocks });
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content.trim().length > 0) {
|
||||
params.push({
|
||||
|
|
@ -1275,6 +1306,7 @@ function convertMessages(
|
|||
});
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
flushPendingSystemMessages();
|
||||
const blocks: ContentBlockParam[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
|
|
@ -1345,41 +1377,37 @@ function convertMessages(
|
|||
} else if (msg.role === "toolResult") {
|
||||
// Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.
|
||||
const toolResults: ContentBlockParam[] = [];
|
||||
const siblingContent: ContentBlockParam[] = [];
|
||||
let j = i;
|
||||
while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
|
||||
const converted = convertToolResult(
|
||||
transformedMessages[j] as ToolResultMessage,
|
||||
isOAuthToken,
|
||||
deferredToolNames,
|
||||
loadedToolNames,
|
||||
normalizeToolName,
|
||||
);
|
||||
toolResults.push(converted.toolResult);
|
||||
siblingContent.push(...converted.siblingContent);
|
||||
toolResults.push(convertToolResult(transformedMessages[j] as ToolResultMessage));
|
||||
j++;
|
||||
}
|
||||
|
||||
// Skip the messages we've already processed.
|
||||
i = j - 1;
|
||||
|
||||
// Displaced reference-bearing results must follow every tool_result block.
|
||||
params.push({
|
||||
role: "user",
|
||||
content: [...toolResults, ...siblingContent],
|
||||
content: toolResults,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache_control to the last user message to cache conversation history
|
||||
flushPendingSystemMessages();
|
||||
|
||||
// Add cache_control to the last user or system message to cache conversation history
|
||||
if (cacheControl && params.length > 0) {
|
||||
const lastMessage = params[params.length - 1];
|
||||
if (lastMessage.role === "user") {
|
||||
if (lastMessage.role === "user" || lastMessage.role === "system") {
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
|
||||
if (
|
||||
lastBlock &&
|
||||
(lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")
|
||||
(lastBlock.type === "text" ||
|
||||
lastBlock.type === "image" ||
|
||||
lastBlock.type === "tool_result" ||
|
||||
lastBlock.type === "tool_addition" ||
|
||||
lastBlock.type === "tool_removal")
|
||||
) {
|
||||
(lastBlock as any).cache_control = cacheControl;
|
||||
}
|
||||
|
|
@ -1418,8 +1446,11 @@ function insertThinkingLevelMessages(
|
|||
return messages;
|
||||
}
|
||||
|
||||
function shouldUseFineGrainedToolStreamingBeta(model: Model<"anthropic-messages">, context: Context): boolean {
|
||||
return !!context.tools?.length && !getAnthropicCompat(model).supportsEagerToolInputStreaming;
|
||||
function shouldUseFineGrainedToolStreamingBeta(
|
||||
model: Model<"anthropic-messages">,
|
||||
context: TranscriptContext,
|
||||
): boolean {
|
||||
return getCurrentTools(context.messages).length > 0 && !getAnthropicCompat(model).supportsEagerToolInputStreaming;
|
||||
}
|
||||
|
||||
function convertTools(
|
||||
|
|
@ -1428,7 +1459,6 @@ function convertTools(
|
|||
supportsEagerToolInputStreaming: boolean,
|
||||
supportsStrictTools: boolean,
|
||||
cacheControl?: CacheControlEphemeral,
|
||||
deferLoading = false,
|
||||
): BetaTool[] {
|
||||
if (!tools) return [];
|
||||
|
||||
|
|
@ -1455,7 +1485,6 @@ function convertTools(
|
|||
...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
|
||||
...(strict === true ? { strict: true } : {}),
|
||||
input_schema: inputSchema,
|
||||
...(deferLoading ? { defer_loading: true } : {}),
|
||||
...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { clampThinkingLevel } from "../models.ts";
|
|||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
|
|
@ -16,6 +16,7 @@ import { headersToRecord } from "../utils/headers.ts";
|
|||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
|
|
@ -69,10 +70,11 @@ export interface AzureOpenAIResponsesOptions extends StreamOptions {
|
|||
*/
|
||||
export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = (
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: AzureOpenAIResponsesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, model.compat?.supportsMidConvoSystemMessages);
|
||||
|
||||
// Start async processing
|
||||
(async () => {
|
||||
|
|
@ -104,10 +106,10 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
|
|||
}
|
||||
const client = createClient(model, apiKey, options);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(normalizedContext.messages),
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
);
|
||||
let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties);
|
||||
let params = buildParams(model, normalizedContext, options, deploymentName, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
|
|
@ -162,7 +164,7 @@ export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIRespons
|
|||
|
||||
export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStreamOptions> = (
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
|
|
@ -274,16 +276,26 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
|
|||
|
||||
function buildParams(
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: AzureOpenAIResponsesOptions | undefined,
|
||||
deploymentName: string,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(context.messages),
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
),
|
||||
) {
|
||||
const supportsAdditionalTools = model.compat?.supportsAdditionalTools ?? false;
|
||||
const supportsToolSearch = model.compat?.supportsToolSearch ?? false;
|
||||
const transcriptTools = resolveTranscriptTools(context.messages, supportsAdditionalTools || supportsToolSearch);
|
||||
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
|
||||
supportsAdditionalTools,
|
||||
supportsToolSearch,
|
||||
toolOptions: {
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
|
|
@ -302,8 +314,8 @@ function buildParams(
|
|||
params.temperature = options?.temperature;
|
||||
}
|
||||
|
||||
if (context.tools && context.tools.length > 0) {
|
||||
params.tools = convertResponsesTools(context.tools, {
|
||||
if (transcriptTools.requestTools.length > 0) {
|
||||
params.tools = convertResponsesTools(transcriptTools.requestTools, {
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import type {
|
|||
Api,
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
|
|
@ -56,6 +55,14 @@ import { parseStreamingJson } from "../utils/json-parse.ts";
|
|||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import {
|
||||
collapseSystemMessages,
|
||||
getCurrentTools,
|
||||
getInitialSystemMessage,
|
||||
type TranscriptContext,
|
||||
withoutInitialSystemMessage,
|
||||
} from "../utils/transcript.ts";
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import {
|
||||
adjustMaxTokensForThinking,
|
||||
|
|
@ -115,10 +122,12 @@ const REDACTED_THINKING_PLACEHOLDER = "[Reasoning redacted]";
|
|||
|
||||
export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: BedrockOptions = {},
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
// Bedrock has no mid-conversation system messages; fold them into the leading prompt.
|
||||
const normalizedContext = collapseSystemMessages(context);
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
|
|
@ -248,15 +257,21 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
|||
}
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env);
|
||||
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
|
||||
const initialSystemMessage = getInitialSystemMessage(normalizedContext.messages);
|
||||
const initialSystemPrompt = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : undefined;
|
||||
let commandInput = {
|
||||
modelId: model.id,
|
||||
messages: convertMessages(context, model, cacheRetention, options.env),
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env),
|
||||
messages: convertMessages(normalizedContext, model, cacheRetention, options.env),
|
||||
system: buildSystemPrompt(initialSystemPrompt, model, cacheRetention, options.env),
|
||||
inferenceConfig: {
|
||||
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
|
||||
...(options.temperature !== undefined && { temperature: options.temperature }),
|
||||
},
|
||||
toolConfig: convertToolConfig(context.tools, options.toolChoice, supportsStrictMode),
|
||||
toolConfig: convertToolConfig(
|
||||
getCurrentTools(normalizedContext.messages),
|
||||
options.toolChoice,
|
||||
supportsStrictMode,
|
||||
),
|
||||
additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
|
||||
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
|
||||
};
|
||||
|
|
@ -510,7 +525,7 @@ function addResponseHeadersMiddleware(
|
|||
|
||||
export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const base = {
|
||||
|
|
@ -930,13 +945,17 @@ function convertToolResultContent(content: (TextContent | ImageContent)[]): Tool
|
|||
}
|
||||
|
||||
function convertMessages(
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): Message[] {
|
||||
const result: Message[] = [];
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const transformedMessages = transformMessages(
|
||||
withoutInitialSystemMessage(context.messages),
|
||||
model,
|
||||
normalizeToolCallId,
|
||||
);
|
||||
|
||||
for (let i = 0; i < transformedMessages.length; i++) {
|
||||
const m = transformedMessages[i];
|
||||
|
|
@ -963,10 +982,7 @@ function convertMessages(
|
|||
}
|
||||
if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER });
|
||||
}
|
||||
result.push({
|
||||
role: ConversationRole.USER,
|
||||
content,
|
||||
});
|
||||
result.push({ role: ConversationRole.USER, content });
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
|
|
@ -1076,10 +1092,7 @@ function convertMessages(
|
|||
// Skip the messages we've already processed
|
||||
i = j - 1;
|
||||
|
||||
result.push({
|
||||
role: ConversationRole.USER,
|
||||
content: toolResults,
|
||||
});
|
||||
result.push({ role: ConversationRole.USER, content: toolResults });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { calculateCost, clampThinkingLevel } from "../models.ts";
|
|||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderHeaders,
|
||||
SimpleStreamOptions,
|
||||
|
|
@ -18,12 +17,15 @@ import type {
|
|||
ThinkingBudgets,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import { collapseSystemMessages, getCurrentTools, getInitialSystemMessage } from "../utils/transcript.ts";
|
||||
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
|
||||
import {
|
||||
convertMessages,
|
||||
|
|
@ -52,10 +54,11 @@ let toolCallCounter = 0;
|
|||
|
||||
export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: GoogleOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = collapseSystemMessages(context);
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
|
|
@ -85,7 +88,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
|||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options?.headers);
|
||||
let params = buildParams(model, context, options);
|
||||
let params = buildParams(model, normalizedContext, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
|
|
@ -296,7 +299,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
|||
|
||||
export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOptions> = (
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
|
|
@ -358,10 +361,12 @@ function createClient(
|
|||
|
||||
function buildParams(
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: GoogleOptions = {},
|
||||
): GenerateContentParameters {
|
||||
const contents = convertMessages(model, context);
|
||||
const initialSystemMessage = getInitialSystemMessage(context.messages);
|
||||
const currentTools = getCurrentTools(context.messages);
|
||||
|
||||
const generationConfig: GenerateContentConfig = {};
|
||||
if (options.temperature !== undefined) {
|
||||
|
|
@ -372,16 +377,17 @@ function buildParams(
|
|||
}
|
||||
|
||||
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const functionCallingMode =
|
||||
currentTools.length > 0
|
||||
? resolveGoogleFunctionCallingMode(currentTools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const systemInstruction = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools &&
|
||||
context.tools.length > 0 && {
|
||||
tools: convertTools(context.tools, false, supportsStrictMode),
|
||||
}),
|
||||
...(systemInstruction && { systemInstruction: sanitizeSurrogates(systemInstruction) }),
|
||||
...(currentTools.length > 0 && {
|
||||
tools: convertTools(currentTools, false, supportsStrictMode),
|
||||
}),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type {
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
|
|
@ -13,9 +12,11 @@ import type {
|
|||
TextContent,
|
||||
ThinkingLevel,
|
||||
Tool,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { collapseSystemMessages, withoutInitialSystemMessage } from "../utils/transcript.ts";
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
|
|
@ -128,14 +129,16 @@ function supportsMultimodalFunctionResponse(modelId: string): boolean {
|
|||
/**
|
||||
* Convert internal messages to Gemini Content[] format.
|
||||
*/
|
||||
export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
|
||||
export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: TranscriptContext): Content[] {
|
||||
// Gemini has no mid-conversation system messages; the leading prompt is sent as systemInstruction.
|
||||
const conversation = withoutInitialSystemMessage(collapseSystemMessages(context).messages);
|
||||
const contents: Content[] = [];
|
||||
const normalizeToolCallId = (id: string): string => {
|
||||
if (!requiresToolCallId(model.id)) return id;
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
const transformedMessages = transformMessages(conversation, model, normalizeToolCallId);
|
||||
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { calculateCost, clampThinkingLevel } from "../models.ts";
|
|||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
ProviderHeaders,
|
||||
|
|
@ -22,6 +21,7 @@ import type {
|
|||
ThinkingBudgets,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
|
|
@ -29,6 +29,8 @@ import { providerHeadersToRecord } from "../utils/headers.ts";
|
|||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import { collapseSystemMessages, getCurrentTools, getInitialSystemMessage } from "../utils/transcript.ts";
|
||||
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
|
||||
import {
|
||||
convertMessages,
|
||||
|
|
@ -70,10 +72,11 @@ let toolCallCounter = 0;
|
|||
|
||||
export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: GoogleVertexOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = collapseSystemMessages(context);
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
|
|
@ -103,7 +106,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
|||
const client = apiKey
|
||||
? createClientWithApiKey(model, apiKey, options?.headers)
|
||||
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
|
||||
let params = buildParams(model, context, options);
|
||||
let params = buildParams(model, normalizedContext, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
|
|
@ -313,7 +316,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
|||
|
||||
export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> = (
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const base = {
|
||||
|
|
@ -457,10 +460,12 @@ function resolveLocation(options?: GoogleVertexOptions): string {
|
|||
|
||||
function buildParams(
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: GoogleVertexOptions = {},
|
||||
): GenerateContentParameters {
|
||||
const contents = convertMessages(model, context);
|
||||
const initialSystemMessage = getInitialSystemMessage(context.messages);
|
||||
const currentTools = getCurrentTools(context.messages);
|
||||
|
||||
const generationConfig: GenerateContentConfig = {};
|
||||
if (options.temperature !== undefined) {
|
||||
|
|
@ -471,16 +476,17 @@ function buildParams(
|
|||
}
|
||||
|
||||
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const functionCallingMode =
|
||||
currentTools.length > 0
|
||||
? resolveGoogleFunctionCallingMode(currentTools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const systemInstruction = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools &&
|
||||
context.tools.length > 0 && {
|
||||
tools: convertTools(context.tools, false, supportsStrictMode),
|
||||
}),
|
||||
...(systemInstruction && { systemInstruction: sanitizeSurrogates(systemInstruction) }),
|
||||
...(currentTools.length > 0 && {
|
||||
tools: convertTools(currentTools, false, supportsStrictMode),
|
||||
}),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { calculateCost, clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
|
|
@ -12,6 +11,7 @@ import type {
|
|||
ThinkingContent,
|
||||
Tool,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
|
|
@ -19,6 +19,8 @@ import { headersToRecord } from "../utils/headers.ts";
|
|||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import { getCurrentTools, resolveTranscript } from "../utils/transcript.ts";
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
|
@ -121,10 +123,11 @@ type MistralCompletionEvent = {
|
|||
*/
|
||||
export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: MistralOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, model.compat?.supportsMidConvoSystemMessages);
|
||||
|
||||
(async () => {
|
||||
const output = createOutput(model);
|
||||
|
|
@ -136,9 +139,11 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
|
|||
}
|
||||
|
||||
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
|
||||
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
|
||||
const transformedMessages = transformMessages(normalizedContext.messages, model, (id) =>
|
||||
normalizeMistralToolCallId(id),
|
||||
);
|
||||
|
||||
let payload = buildChatPayload(model, context, transformedMessages, options);
|
||||
let payload = buildChatPayload(model, normalizedContext, transformedMessages, options);
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
if (nextPayload !== undefined) {
|
||||
payload = nextPayload as MistralChatPayload;
|
||||
|
|
@ -180,7 +185,7 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
|
|||
*/
|
||||
export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamOptions> = (
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
|
|
@ -502,7 +507,7 @@ function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL
|
|||
|
||||
function buildChatPayload(
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
messages: Message[],
|
||||
options?: MistralOptions,
|
||||
): MistralChatPayload {
|
||||
|
|
@ -512,7 +517,8 @@ function buildChatPayload(
|
|||
messages: toChatMessages(messages, model.input.includes("image")),
|
||||
};
|
||||
|
||||
if (context.tools?.length) payload.tools = toFunctionTools(context.tools);
|
||||
const currentTools = getCurrentTools(context.messages);
|
||||
if (currentTools.length > 0) payload.tools = toFunctionTools(currentTools);
|
||||
if (options?.temperature !== undefined) payload.temperature = options.temperature;
|
||||
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
|
||||
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
|
||||
|
|
@ -520,13 +526,6 @@ function buildChatPayload(
|
|||
if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort;
|
||||
if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId;
|
||||
|
||||
if (context.systemPrompt) {
|
||||
payload.messages.unshift({
|
||||
role: "system",
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
|
@ -784,7 +783,13 @@ function stripSymbolKeys(value: unknown): unknown {
|
|||
function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] {
|
||||
const result: MistralChatMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const [index, msg] of messages.entries()) {
|
||||
if (msg.role === "system") {
|
||||
const text = index === 0 ? getSystemMessageText(msg) : renderSystemMessageUpdate(msg);
|
||||
if (text.length > 0) result.push({ role: "system", content: sanitizeSurrogates(text) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
||||
|
|
|
|||
|
|
@ -11,17 +11,16 @@ import { registerSessionResourceCleanup } from "../session-resources.ts";
|
|||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
ProviderHeaders,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import { combineAbortSignals } from "../utils/abort-signals.ts";
|
||||
import { splitDeferredTools } from "../utils/deferred-tools.ts";
|
||||
import {
|
||||
appendAssistantMessageDiagnostic,
|
||||
createAssistantMessageDiagnostic,
|
||||
|
|
@ -32,6 +31,14 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
|||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
import {
|
||||
getDeclaredTools,
|
||||
getInitialSystemMessage,
|
||||
normalizeContext,
|
||||
resolveTranscript,
|
||||
resolveTranscriptTools,
|
||||
} from "../utils/transcript.ts";
|
||||
import { uuidv7 } from "../utils/uuid.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
|
|
@ -229,10 +236,11 @@ function compressRequestBodyZstd(bodyJson: string): Uint8Array | null {
|
|||
|
||||
export const stream: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions> = (
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: OpenAICodexResponsesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, model.compat?.supportsMidConvoSystemMessages);
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
|
|
@ -261,12 +269,12 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||
|
||||
const accountId = extractAccountId(apiKey);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(normalizedContext.messages),
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
);
|
||||
const cacheSessionId = options?.cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const codexSessionId = clampOpenAIPromptCacheKey(cacheSessionId);
|
||||
let body = buildRequestBody(model, context, options, codexSessionId, grammarToolInputProperties);
|
||||
let body = buildRequestBody(model, normalizedContext, options, codexSessionId, grammarToolInputProperties);
|
||||
const nextBody = await options?.onPayload?.(body, model);
|
||||
if (nextBody !== undefined) {
|
||||
body = nextBody as RequestBody;
|
||||
|
|
@ -491,7 +499,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||
|
||||
export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStreamOptions> = (
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
|
|
@ -518,39 +526,35 @@ export const streamSimple: StreamFunction<"openai-codex-responses", SimpleStream
|
|||
|
||||
function buildRequestBody(
|
||||
model: Model<"openai-codex-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: OpenAICodexResponsesOptions | undefined,
|
||||
cacheSessionId: string | undefined,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(context.messages),
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
),
|
||||
): RequestBody {
|
||||
const supportsStrictMode = model.compat?.supportsStrictMode ?? true;
|
||||
const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false;
|
||||
const deferredToolsMode = model.compat?.supportsAdditionalTools
|
||||
? "additional-tools"
|
||||
: model.compat?.supportsToolSearch
|
||||
? "tool-search"
|
||||
: undefined;
|
||||
const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined);
|
||||
const supportsAdditionalTools = model.compat?.supportsAdditionalTools ?? false;
|
||||
const supportsToolSearch = model.compat?.supportsToolSearch ?? false;
|
||||
const transcriptTools = resolveTranscriptTools(context.messages, supportsAdditionalTools || supportsToolSearch);
|
||||
const messages = convertResponsesMessages(model, context, CODEX_TOOL_CALL_PROVIDERS, {
|
||||
includeSystemPrompt: false,
|
||||
grammarToolInputProperties,
|
||||
deferredTools: toolPlacement.deferred,
|
||||
deferredToolsMode,
|
||||
toolOptions: {
|
||||
strict: null,
|
||||
supportsStrictMode,
|
||||
supportsOpenAIGrammarTools,
|
||||
},
|
||||
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
|
||||
supportsAdditionalTools,
|
||||
supportsToolSearch,
|
||||
toolOptions: { strict: null, supportsStrictMode, supportsOpenAIGrammarTools },
|
||||
});
|
||||
|
||||
const initialSystemMessage = getInitialSystemMessage(context.messages);
|
||||
const instructions = initialSystemMessage ? getSystemMessageText(initialSystemMessage) : "";
|
||||
const body: RequestBody = {
|
||||
model: model.id,
|
||||
store: false,
|
||||
stream: true,
|
||||
instructions: context.systemPrompt || "You are a helpful assistant.",
|
||||
instructions: instructions || "You are a helpful assistant.",
|
||||
input: messages,
|
||||
text: { verbosity: options?.textVerbosity || "low" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
|
|
@ -567,8 +571,8 @@ function buildRequestBody(
|
|||
body.service_tier = options.serviceTier;
|
||||
}
|
||||
|
||||
if (toolPlacement.immediate.length > 0) {
|
||||
body.tools = convertResponsesTools(toolPlacement.immediate, {
|
||||
if (transcriptTools.requestTools.length > 0) {
|
||||
body.tools = convertResponsesTools(transcriptTools.requestTools, {
|
||||
strict: null,
|
||||
supportsStrictMode,
|
||||
supportsOpenAIGrammarTools,
|
||||
|
|
@ -1528,10 +1532,15 @@ async function processWebSocketStream(
|
|||
if (options?.signal?.aborted) {
|
||||
keepConnection = false;
|
||||
} else if (useCachedContext && entry && output.responseId) {
|
||||
const responseItems = convertResponsesMessages(model, { messages: [output] }, CODEX_TOOL_CALL_PROVIDERS, {
|
||||
includeSystemPrompt: false,
|
||||
grammarToolInputProperties,
|
||||
}).filter((item) => item.type !== "function_call_output" && item.type !== "custom_tool_call_output");
|
||||
const responseItems = convertResponsesMessages(
|
||||
model,
|
||||
normalizeContext({ messages: [output] }),
|
||||
CODEX_TOOL_CALL_PROVIDERS,
|
||||
{
|
||||
includeSystemPrompt: false,
|
||||
grammarToolInputProperties,
|
||||
},
|
||||
).filter((item) => item.type !== "function_call_output" && item.type !== "custom_tool_call_output");
|
||||
entry.continuation = {
|
||||
lastRequestBody: fullBody,
|
||||
lastResponseId: output.responseId,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import type {
|
|||
AssistantMessage,
|
||||
CacheRetention,
|
||||
ChatTemplateKwargValue,
|
||||
Context,
|
||||
ImageContent,
|
||||
JsonValue,
|
||||
Message,
|
||||
|
|
@ -45,6 +44,13 @@ import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
|||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import {
|
||||
getDeclaredTools,
|
||||
resolveTranscript,
|
||||
resolveTranscriptTools,
|
||||
type TranscriptContext,
|
||||
} from "../utils/transcript.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
createGrammarToolInputProperties,
|
||||
|
|
@ -93,26 +99,6 @@ function hasToolHistory(messages: Message[]): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function getDeferredToolNames(messages: Message[]): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "toolResult") {
|
||||
for (const name of message.addedToolNames ?? []) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function getToolsByName(tools: Tool[] | undefined, names: Iterable<string>): Tool[] {
|
||||
if (!tools) return [];
|
||||
const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
return Array.from(names)
|
||||
.map((name) => toolsByName.get(name))
|
||||
.filter((tool): tool is Tool => tool !== undefined);
|
||||
}
|
||||
|
||||
function isTextContentBlock(block: { type: string }): block is TextContent {
|
||||
return block.type === "text";
|
||||
}
|
||||
|
|
@ -179,15 +165,17 @@ interface OpenAICompatCacheControl {
|
|||
type ResolvedOpenAICompletionsCompat = Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
| "cacheControlFormat"
|
||||
| "deferredToolsMode"
|
||||
| "supportsThinkingTokenBudget"
|
||||
| "thinkingTokenBudgetField"
|
||||
| "supportsMidConvoSystemMessages"
|
||||
| "supportsMidConvoToolAdditions"
|
||||
| "vllmPriority"
|
||||
> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
supportsThinkingTokenBudget?: OpenAICompletionsCompat["supportsThinkingTokenBudget"];
|
||||
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
|
||||
supportsMidConvoSystemMessages?: OpenAICompletionsCompat["supportsMidConvoSystemMessages"];
|
||||
supportsMidConvoToolAdditions?: OpenAICompletionsCompat["supportsMidConvoToolAdditions"];
|
||||
vllmPriority?: OpenAICompletionsCompat["vllmPriority"];
|
||||
};
|
||||
|
||||
|
|
@ -310,10 +298,11 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn
|
|||
|
||||
export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptions> = (
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: OpenAICompletionsOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, getCompat(model).supportsMidConvoSystemMessages);
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
|
|
@ -347,13 +336,28 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
|||
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
const compat = getCompat(model);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(normalizedContext.messages),
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId, compat);
|
||||
let params = buildParams(model, context, options, compat, cacheRetention, grammarToolInputProperties);
|
||||
const client = createClient(
|
||||
model,
|
||||
normalizedContext,
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.fetch,
|
||||
cacheSessionId,
|
||||
compat,
|
||||
);
|
||||
let params = buildParams(
|
||||
model,
|
||||
normalizedContext,
|
||||
options,
|
||||
compat,
|
||||
cacheRetention,
|
||||
grammarToolInputProperties,
|
||||
);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming;
|
||||
|
|
@ -725,7 +729,7 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
|
|||
|
||||
export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOptions> = (
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
|
|
@ -746,7 +750,7 @@ export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOpti
|
|||
|
||||
function createClient(
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
|
|
@ -791,16 +795,22 @@ function createClient(
|
|||
|
||||
function buildParams(
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: OpenAICompletionsOptions,
|
||||
compat: ResolvedOpenAICompletionsCompat = getCompat(model),
|
||||
cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env),
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(context.messages),
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
),
|
||||
) {
|
||||
const messages = convertMessages(model, context, compat, { grammarToolInputProperties });
|
||||
const transcriptTools = resolveTranscriptTools(
|
||||
context.messages,
|
||||
compat.supportsMidConvoSystemMessages === true && compat.supportsMidConvoToolAdditions === true,
|
||||
);
|
||||
const messages = convertMessages(model, context, compat, {
|
||||
grammarToolInputProperties,
|
||||
});
|
||||
const cacheControl = getCompatCacheControl(compat, cacheRetention);
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
|
|
@ -835,11 +845,8 @@ function buildParams(
|
|||
params.temperature = options.temperature;
|
||||
}
|
||||
|
||||
const deferredToolNames =
|
||||
compat.deferredToolsMode === "kimi" ? getDeferredToolNames(context.messages) : new Set<string>();
|
||||
const activeTools = context.tools?.filter((tool) => !deferredToolNames.has(tool.name));
|
||||
if (activeTools && activeTools.length > 0) {
|
||||
params.tools = convertTools(activeTools, compat);
|
||||
if (transcriptTools.requestTools.length > 0) {
|
||||
params.tools = convertTools(transcriptTools.requestTools, compat);
|
||||
if (compat.zaiToolStream) {
|
||||
(params as any).tool_stream = true;
|
||||
}
|
||||
|
|
@ -1177,10 +1184,11 @@ function addCacheControlToTextContent(
|
|||
|
||||
export function convertMessages(
|
||||
model: Model<"openai-completions">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
compat: ResolvedOpenAICompletionsCompat,
|
||||
options?: ConvertCompletionsMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const normalizedContext = resolveTranscript(context, compat.supportsMidConvoSystemMessages);
|
||||
const params: ChatCompletionMessageParam[] = [];
|
||||
|
||||
const normalizeToolCallId = (id: string): string => {
|
||||
|
|
@ -1209,13 +1217,12 @@ export function convertMessages(
|
|||
return id;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id));
|
||||
|
||||
if (context.systemPrompt) {
|
||||
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
|
||||
const role = useDeveloperRole ? "developer" : "system";
|
||||
params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) });
|
||||
}
|
||||
const transformedMessages = transformMessages(normalizedContext.messages, model, (id) => normalizeToolCallId(id));
|
||||
const transcriptTools = resolveTranscriptTools(
|
||||
normalizedContext.messages,
|
||||
compat.supportsMidConvoSystemMessages === true && compat.supportsMidConvoToolAdditions === true,
|
||||
);
|
||||
const instructionRole = model.reasoning && compat.supportsDeveloperRole ? "developer" : "system";
|
||||
|
||||
let lastRole: string | null = null;
|
||||
|
||||
|
|
@ -1230,7 +1237,20 @@ export function convertMessages(
|
|||
});
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
if (msg.role === "system") {
|
||||
const addedTools = i > 0 && transcriptTools.anchorsAdditions ? (msg.toolsAdded ?? []) : [];
|
||||
if (addedTools.length > 0) {
|
||||
const kimiToolMessage: KimiToolSystemMessageParam = {
|
||||
role: "system",
|
||||
tools: convertTools(addedTools, compat),
|
||||
};
|
||||
params.push(kimiToolMessage as unknown as ChatCompletionMessageParam);
|
||||
}
|
||||
const text = i === 0 ? getSystemMessageText(msg) : renderSystemMessageUpdate(msg);
|
||||
if (text.length > 0) {
|
||||
params.push({ role: instructionRole, content: sanitizeSurrogates(text) });
|
||||
}
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
params.push({
|
||||
role: "user",
|
||||
|
|
@ -1375,7 +1395,6 @@ export function convertMessages(
|
|||
params.push(assistantMsg);
|
||||
} else if (msg.role === "toolResult") {
|
||||
const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = [];
|
||||
const deferredToolNames = new Set<string>();
|
||||
let j = i;
|
||||
|
||||
for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) {
|
||||
|
|
@ -1402,12 +1421,6 @@ export function convertMessages(
|
|||
}
|
||||
params.push(toolResultMsg);
|
||||
|
||||
if (compat.deferredToolsMode === "kimi") {
|
||||
for (const name of toolMsg.addedToolNames ?? []) {
|
||||
deferredToolNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasImages && model.input.includes("image")) {
|
||||
for (const block of toolMsg.content) {
|
||||
if (isImageContentBlock(block)) {
|
||||
|
|
@ -1447,17 +1460,6 @@ export function convertMessages(
|
|||
lastRole = "toolResult";
|
||||
}
|
||||
|
||||
if (deferredToolNames.size > 0) {
|
||||
const deferredTools = getToolsByName(context.tools, deferredToolNames);
|
||||
if (deferredTools.length > 0) {
|
||||
const kimiToolMessage: KimiToolSystemMessageParam = {
|
||||
role: "system",
|
||||
tools: convertTools(deferredTools, compat),
|
||||
};
|
||||
// Kimi accepts a system message with tools but omits the standard content field.
|
||||
params.push(kimiToolMessage as unknown as ChatCompletionMessageParam);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1661,9 +1663,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
|
|||
thinkingTokenBudgetField: undefined,
|
||||
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
supportsMidConvoSystemMessages: false,
|
||||
supportsMidConvoToolAdditions: false,
|
||||
cacheControlFormat,
|
||||
sendSessionAffinityHeaders: isOpenRouter,
|
||||
deferredToolsMode: undefined,
|
||||
sessionAffinityFormat: isOpenRouter ? "openrouter" : "openai",
|
||||
supportsLongCacheRetention: !(
|
||||
isTogether ||
|
||||
|
|
@ -1707,9 +1710,12 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion
|
|||
thinkingTokenBudgetField: model.compat.thinkingTokenBudgetField ?? detected.thinkingTokenBudgetField,
|
||||
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: model.compat.supportsOpenAIGrammarTools ?? detected.supportsOpenAIGrammarTools,
|
||||
supportsMidConvoSystemMessages:
|
||||
model.compat.supportsMidConvoSystemMessages ?? detected.supportsMidConvoSystemMessages,
|
||||
supportsMidConvoToolAdditions:
|
||||
model.compat.supportsMidConvoToolAdditions ?? detected.supportsMidConvoToolAdditions,
|
||||
cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat,
|
||||
sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
|
||||
deferredToolsMode: model.compat.deferredToolsMode ?? detected.deferredToolsMode,
|
||||
sessionAffinityFormat: model.compat.sessionAffinityFormat ?? detected.sessionAffinityFormat,
|
||||
supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention,
|
||||
vllmPriority: model.compat.vllmPriority,
|
||||
|
|
|
|||
|
|
@ -17,21 +17,24 @@ import { calculateCost } from "../models.ts";
|
|||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
StopReason,
|
||||
SystemMessage,
|
||||
TextContent,
|
||||
TextSignatureV1,
|
||||
ThinkingContent,
|
||||
Tool,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getSystemMessageText, renderSystemMessageUpdate } from "../utils/text.ts";
|
||||
import { resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
type GrammarToolInputJsonBuffer,
|
||||
|
|
@ -119,8 +122,10 @@ export interface OpenAIResponsesStreamOptions {
|
|||
export interface ConvertResponsesMessagesOptions {
|
||||
includeSystemPrompt?: boolean;
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
deferredTools?: ReadonlyMap<string, Tool>;
|
||||
deferredToolsMode?: "additional-tools" | "tool-search";
|
||||
/** Whether later system messages are sent in place; otherwise they are folded into the leading prompt. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
supportsAdditionalTools?: boolean;
|
||||
supportsToolSearch?: boolean;
|
||||
toolOptions?: ConvertResponsesToolsOptions;
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +133,7 @@ export interface ConvertResponsesToolsOptions {
|
|||
strict?: boolean | null;
|
||||
supportsStrictMode?: boolean;
|
||||
supportsOpenAIGrammarTools?: boolean;
|
||||
deferLoading?: boolean;
|
||||
toolSearchResult?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -137,12 +142,12 @@ export interface ConvertResponsesToolsOptions {
|
|||
|
||||
export function convertResponsesMessages<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
allowedToolCallProviders: ReadonlySet<string>,
|
||||
options?: ConvertResponsesMessagesOptions,
|
||||
): ResponseInput {
|
||||
const normalizedContext = resolveTranscript(context, options?.supportsMidConvoSystemMessages);
|
||||
const messages: ResponseInput = [];
|
||||
const loadedToolNames = new Set<string>();
|
||||
|
||||
const normalizeIdPart = (part: string): string => {
|
||||
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
|
|
@ -169,21 +174,57 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
return `${normalizedCallId}|${normalizedItemId}`;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
|
||||
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
|
||||
if (includeSystemPrompt && context.systemPrompt) {
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
const transformedMessages = transformMessages(normalizedContext.messages, model, normalizeToolCallId);
|
||||
const transcriptTools = resolveTranscriptTools(
|
||||
normalizedContext.messages,
|
||||
(options?.supportsAdditionalTools ?? false) || (options?.supportsToolSearch ?? false),
|
||||
);
|
||||
const appendSystemToolAdditions = (message: SystemMessage, seed: string): void => {
|
||||
const tools = transcriptTools.anchorsAdditions ? (message.toolsAdded ?? []) : [];
|
||||
if (tools.length === 0) return;
|
||||
if (options?.supportsAdditionalTools) {
|
||||
messages.push({
|
||||
type: "additional_tools",
|
||||
role: "developer",
|
||||
tools: convertResponsesTools(tools, options.toolOptions),
|
||||
} satisfies ResponseInputItem);
|
||||
return;
|
||||
}
|
||||
if (!options?.supportsToolSearch) return;
|
||||
const names = tools.map((tool) => tool.name);
|
||||
const callId = `pi_tool_load_${shortHash(`${seed}:${names.join(",")}`)}`;
|
||||
messages.push({
|
||||
role,
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
});
|
||||
}
|
||||
type: "tool_search_call",
|
||||
call_id: callId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
arguments: { query: names.join(" "), limit: names.length },
|
||||
} satisfies ResponseInputItem);
|
||||
messages.push({
|
||||
type: "tool_search_output",
|
||||
call_id: callId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(tools, { ...options.toolOptions, toolSearchResult: true }),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
};
|
||||
const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
|
||||
let msgIndex = 0;
|
||||
let sourceIndex = 0;
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
const isLeadingSystemMessage = sourceIndex++ === 0 && msg.role === "system";
|
||||
if (msg.role === "system") {
|
||||
if (!isLeadingSystemMessage) appendSystemToolAdditions(msg, `system:${msgIndex}`);
|
||||
if (!isLeadingSystemMessage || includeInitialSystemMessage) {
|
||||
const text = isLeadingSystemMessage ? getSystemMessageText(msg) : renderSystemMessageUpdate(msg);
|
||||
if (text.length > 0) {
|
||||
messages.push({ role: instructionRole, content: sanitizeSurrogates(text) });
|
||||
}
|
||||
}
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
|
|
@ -262,8 +303,6 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
itemId = undefined;
|
||||
}
|
||||
|
||||
const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true;
|
||||
|
||||
if (customInputProperty !== undefined) {
|
||||
output.push({
|
||||
type: "custom_tool_call",
|
||||
|
|
@ -273,9 +312,7 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
input: sanitizeSurrogates(
|
||||
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
|
||||
),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
...(isSameModel && toolCall.namespace !== undefined ? { namespace: toolCall.namespace } : {}),
|
||||
} satisfies ResponseOutputItem);
|
||||
} else {
|
||||
output.push({
|
||||
|
|
@ -284,9 +321,7 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
...(isSameModel && toolCall.namespace !== undefined ? { namespace: toolCall.namespace } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -310,43 +345,8 @@ export function convertResponsesMessages<TApi extends Api>(
|
|||
output,
|
||||
});
|
||||
}
|
||||
|
||||
const deferredTools: Tool[] = [];
|
||||
for (const name of msg.addedToolNames ?? []) {
|
||||
const tool = options?.deferredTools?.get(name);
|
||||
if (!tool || loadedToolNames.has(name)) continue;
|
||||
loadedToolNames.add(name);
|
||||
deferredTools.push(tool);
|
||||
}
|
||||
if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") {
|
||||
messages.push({
|
||||
type: "additional_tools",
|
||||
role: "developer",
|
||||
tools: convertResponsesTools(deferredTools, options.toolOptions),
|
||||
} satisfies ResponseInputItem);
|
||||
} else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") {
|
||||
const names = deferredTools.map((tool) => tool.name);
|
||||
const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`;
|
||||
messages.push({
|
||||
type: "tool_search_call",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
arguments: { query: names.join(" "), limit: names.length },
|
||||
} satisfies ResponseInputItem);
|
||||
messages.push({
|
||||
type: "tool_search_output",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(deferredTools, {
|
||||
...options.toolOptions,
|
||||
deferLoading: true,
|
||||
}),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
}
|
||||
}
|
||||
msgIndex++;
|
||||
if (!isLeadingSystemMessage) msgIndex++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
|
|
@ -373,7 +373,7 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR
|
|||
syntax: grammar.format,
|
||||
definition: grammar.definition,
|
||||
},
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
...(options?.toolSearchResult ? { defer_loading: true } : {}),
|
||||
} satisfies OpenAITool;
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +386,7 @@ export function convertResponsesTools(tools: readonly Tool[], options?: ConvertR
|
|||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: getJsonSchemaToolParameters(tool, strict === true) as Record<string, unknown>,
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
...(options?.toolSearchResult ? { defer_loading: true } : {}),
|
||||
};
|
||||
if (supportsStrictMode) {
|
||||
functionTool.strict = strict;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
Api,
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Context,
|
||||
Model,
|
||||
OpenAIResponsesCompat,
|
||||
ProviderEnv,
|
||||
|
|
@ -13,15 +12,16 @@ import type {
|
|||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import { splitDeferredTools } from "../utils/deferred-tools.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { getDeclaredTools, resolveTranscript, resolveTranscriptTools } from "../utils/transcript.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
|
|
@ -68,6 +68,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn
|
|||
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
|
||||
return {
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
||||
supportsMidConvoSystemMessages: model.compat?.supportsMidConvoSystemMessages ?? false,
|
||||
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
|
||||
|
|
@ -111,10 +112,11 @@ export interface OpenAIResponsesOptions extends StreamOptions {
|
|||
*/
|
||||
export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> = (
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: OpenAIResponsesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const normalizedContext = resolveTranscript(context, getCompat(model).supportsMidConvoSystemMessages);
|
||||
|
||||
// Start async processing
|
||||
(async () => {
|
||||
|
|
@ -143,11 +145,18 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
|||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const compat = getCompat(model);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(normalizedContext.messages),
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
);
|
||||
const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId);
|
||||
let params = buildParams(model, context, options, compat, grammarToolInputProperties);
|
||||
const client = createClient(
|
||||
model,
|
||||
normalizedContext,
|
||||
apiKey,
|
||||
options?.headers,
|
||||
options?.fetch,
|
||||
cacheSessionId,
|
||||
);
|
||||
let params = buildParams(model, normalizedContext, options, compat, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
|
|
@ -209,7 +218,7 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
|||
|
||||
export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOptions> = (
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
|
|
@ -229,7 +238,7 @@ export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOption
|
|||
|
||||
function createClient(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
|
|
@ -273,24 +282,23 @@ function createClient(
|
|||
|
||||
function buildParams(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: OpenAIResponsesOptions | undefined,
|
||||
compat: Required<OpenAIResponsesCompat> = getCompat(model),
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
getDeclaredTools(context.messages),
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
),
|
||||
) {
|
||||
const deferredToolsMode = compat.supportsAdditionalTools
|
||||
? "additional-tools"
|
||||
: compat.supportsToolSearch
|
||||
? "tool-search"
|
||||
: undefined;
|
||||
const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined);
|
||||
const transcriptTools = resolveTranscriptTools(
|
||||
context.messages,
|
||||
compat.supportsAdditionalTools || compat.supportsToolSearch,
|
||||
);
|
||||
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
deferredTools: toolPlacement.deferred,
|
||||
deferredToolsMode,
|
||||
supportsMidConvoSystemMessages: compat.supportsMidConvoSystemMessages,
|
||||
supportsAdditionalTools: compat.supportsAdditionalTools,
|
||||
supportsToolSearch: compat.supportsToolSearch,
|
||||
toolOptions: {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
|
|
@ -322,8 +330,8 @@ function buildParams(
|
|||
params.service_tier = options.serviceTier;
|
||||
}
|
||||
|
||||
if (toolPlacement.immediate.length > 0) {
|
||||
params.tools = convertResponsesTools(toolPlacement.immediate, {
|
||||
if (transcriptTools.requestTools.length > 0) {
|
||||
params.tools = convertResponsesTools(transcriptTools.requestTools, {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import type {
|
|||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
CacheRetention,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
|
|
@ -21,6 +20,7 @@ import type {
|
|||
StreamOptions,
|
||||
ThinkingLevel,
|
||||
ToolCall,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
|
|
@ -352,7 +352,7 @@ function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEn
|
|||
|
||||
export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
|
||||
model: Model<"pi-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: PiMessagesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const eventStream = new AssistantMessageEventStream();
|
||||
|
|
@ -428,7 +428,7 @@ export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
|
|||
|
||||
export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = (
|
||||
model: Model<"pi-messages">,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const extra = options as PiMessagesOptions | undefined;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import type {
|
||||
Api,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamOptions,
|
||||
ThinkingBudgets,
|
||||
ThinkingLevel,
|
||||
TranscriptContext,
|
||||
} from "../types.ts";
|
||||
import { estimateContextTokens } from "../utils/estimate.ts";
|
||||
|
||||
const CONTEXT_SAFETY_TOKENS = 4096;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
|
||||
export function clampMaxTokensToContext(model: Model<Api>, context: Context, maxTokens: number): number {
|
||||
export function clampMaxTokensToContext(model: Model<Api>, context: TranscriptContext, maxTokens: number): number {
|
||||
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
|
||||
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
|
||||
|
|
@ -20,7 +20,7 @@ export function clampMaxTokensToContext(model: Model<Api>, context: Context, max
|
|||
|
||||
export function buildBaseOptions(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
apiKey?: string,
|
||||
): StreamOptions {
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ export function transformMessages<TApi extends Api>(
|
|||
|
||||
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
|
||||
const transformed = imageAwareMessages.map((msg) => {
|
||||
// User messages pass through unchanged
|
||||
if (msg.role === "user") {
|
||||
// System and user messages pass through unchanged
|
||||
if (msg.role === "system" || msg.role === "user") {
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +160,11 @@ export function transformMessages<TApi extends Api>(
|
|||
const result: Message[] = [];
|
||||
let pendingToolCalls: ToolCall[] = [];
|
||||
let existingToolResultIds = new Set<string>();
|
||||
const insertSyntheticToolResults = () => {
|
||||
// System messages are transparent to tool-call accounting: one that lands between a tool
|
||||
// call and its results is held back and emitted after the results (synthetic ones
|
||||
// included), so it never causes a duplicate result for a call that is answered later.
|
||||
const heldSystemMessages: Message[] = [];
|
||||
const closePendingToolCalls = () => {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
for (const tc of pendingToolCalls) {
|
||||
if (!existingToolResultIds.has(tc.id)) {
|
||||
|
|
@ -177,6 +181,8 @@ export function transformMessages<TApi extends Api>(
|
|||
pendingToolCalls = [];
|
||||
existingToolResultIds = new Set();
|
||||
}
|
||||
result.push(...heldSystemMessages);
|
||||
heldSystemMessages.length = 0;
|
||||
};
|
||||
|
||||
for (let i = 0; i < transformed.length; i++) {
|
||||
|
|
@ -184,7 +190,7 @@ export function transformMessages<TApi extends Api>(
|
|||
|
||||
if (msg.role === "assistant") {
|
||||
// If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
|
||||
insertSyntheticToolResults();
|
||||
closePendingToolCalls();
|
||||
|
||||
// Skip errored/aborted assistant messages entirely.
|
||||
// These are incomplete turns that shouldn't be replayed:
|
||||
|
|
@ -207,9 +213,15 @@ export function transformMessages<TApi extends Api>(
|
|||
} else if (msg.role === "toolResult") {
|
||||
existingToolResultIds.add(msg.toolCallId);
|
||||
result.push(msg);
|
||||
} else if (msg.role === "system") {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
heldSystemMessages.push(msg);
|
||||
} else {
|
||||
result.push(msg);
|
||||
}
|
||||
} else if (msg.role === "user") {
|
||||
// User message interrupts tool flow - insert synthetic results for orphaned calls
|
||||
insertSyntheticToolResults();
|
||||
// A new user turn interrupts tool flow - insert synthetic results for orphaned calls
|
||||
closePendingToolCalls();
|
||||
result.push(msg);
|
||||
} else {
|
||||
result.push(msg);
|
||||
|
|
@ -217,7 +229,7 @@ export function transformMessages<TApi extends Api>(
|
|||
}
|
||||
|
||||
// If the conversation ends with unresolved tool calls, synthesize results now.
|
||||
insertSyntheticToolResults();
|
||||
closePendingToolCalls();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ import type {
|
|||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TranscriptContext,
|
||||
} from "./types.ts";
|
||||
import { normalizeContext } from "./utils/transcript.ts";
|
||||
|
||||
/** @deprecated Static catalog read. Use `getBuiltinModel` from "@earendil-works/pi-ai/providers/all" or `Models.getModel()`. */
|
||||
export const getModel = getBuiltinModel;
|
||||
|
|
@ -70,13 +72,13 @@ export const getProviders = getBuiltinProviders;
|
|||
|
||||
export type ApiStreamFunction = (
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: StreamOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
export type ApiStreamSimpleFunction = (
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
|
|
@ -252,15 +254,16 @@ export function stream<TApi extends Api>(
|
|||
context: Context,
|
||||
options?: ProviderStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const transcript = normalizeContext(context);
|
||||
const builtinProvider = getBuiltinProviderForModel(model);
|
||||
if (builtinProvider) {
|
||||
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
|
||||
return compatModels.stream(model, context, options as ModelsApiStreamOptions<TApi> | undefined);
|
||||
return compatModels.stream(model, transcript, options as ModelsApiStreamOptions<TApi> | undefined);
|
||||
}
|
||||
return builtinProvider.stream(model, context, withEnvApiKey(model, options) as ApiStreamOptions<TApi>);
|
||||
return builtinProvider.stream(model, transcript, withEnvApiKey(model, options) as ApiStreamOptions<TApi>);
|
||||
}
|
||||
const provider = resolveApiProvider(model.api);
|
||||
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
|
||||
return provider.stream(model, transcript, withEnvApiKey(model, options) as StreamOptions);
|
||||
}
|
||||
|
||||
export async function complete<TApi extends Api>(
|
||||
|
|
@ -277,15 +280,16 @@ export function streamSimple<TApi extends Api>(
|
|||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const transcript = normalizeContext(context);
|
||||
const builtinProvider = getBuiltinProviderForModel(model);
|
||||
if (builtinProvider) {
|
||||
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
|
||||
return compatModels.streamSimple(model, context, options);
|
||||
return compatModels.streamSimple(model, transcript, options);
|
||||
}
|
||||
return builtinProvider.streamSimple(model, context, withEnvApiKey(model, options));
|
||||
return builtinProvider.streamSimple(model, transcript, withEnvApiKey(model, options));
|
||||
}
|
||||
const provider = resolveApiProvider(model.api);
|
||||
return provider.streamSimple(model, context, withEnvApiKey(model, options));
|
||||
return provider.streamSimple(model, transcript, withEnvApiKey(model, options));
|
||||
}
|
||||
|
||||
export async function completeSimple<TApi extends Api>(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ export * from "./utils/event-stream.ts";
|
|||
export * from "./utils/json-parse.ts";
|
||||
export * from "./utils/overflow.ts";
|
||||
export * from "./utils/retry.ts";
|
||||
export { contentText } from "./utils/text.ts";
|
||||
export { contentText, getSystemMessageText, renderSystemMessageUpdate } from "./utils/text.ts";
|
||||
export * from "./utils/transcript.ts";
|
||||
export * from "./utils/typebox-helpers.ts";
|
||||
export { uuidv7 } from "./utils/uuid.ts";
|
||||
export * from "./utils/validation.ts";
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ import type {
|
|||
ProviderRequestOptions,
|
||||
ProviderStreams,
|
||||
SimpleStreamOptions,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "./types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
|
||||
import { normalizeContext } from "./utils/transcript.ts";
|
||||
|
||||
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
|
||||
|
||||
|
|
@ -133,13 +135,18 @@ export interface Provider<TApi extends Api = Api> {
|
|||
*/
|
||||
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
|
||||
|
||||
/** Stream a normalized transcript. `Models` normalizes the caller's `Context` before dispatching here. */
|
||||
stream<T extends TApi>(
|
||||
model: Model<T>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: ApiStreamOptions<T>,
|
||||
): AssistantMessageEventStream;
|
||||
|
||||
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
streamSimple(
|
||||
model: Model<TApi>,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream;
|
||||
fetchDeferred?(
|
||||
model: Model<TApi>,
|
||||
handle: DeferredHandle,
|
||||
|
|
@ -674,13 +681,14 @@ class ModelsImpl implements MutableModels {
|
|||
context: Context,
|
||||
options?: ModelsApiStreamOptions<TApi>,
|
||||
): AssistantMessageEventStream {
|
||||
const transcript = normalizeContext(context);
|
||||
return lazyStream(model, async () => {
|
||||
const provider = this.requireProvider(model);
|
||||
const { requestModel, requestOptions } = await this.applyAuth(
|
||||
model,
|
||||
options as ModelsApiStreamOptions<Api> | undefined,
|
||||
);
|
||||
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
|
||||
return provider.stream(requestModel as Model<TApi>, transcript, requestOptions as ApiStreamOptions<TApi>);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -693,10 +701,11 @@ class ModelsImpl implements MutableModels {
|
|||
}
|
||||
|
||||
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
|
||||
const transcript = normalizeContext(context);
|
||||
return lazyStream(model, async () => {
|
||||
const provider = this.requireProvider(model);
|
||||
const { requestModel, requestOptions } = await this.applyAuth(model, options);
|
||||
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
|
||||
return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createProvider, type Provider } from "../models.ts";
|
|||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
DeferredCancelOptions,
|
||||
DeferredFetchOptions,
|
||||
DeferredHandle,
|
||||
|
|
@ -16,9 +15,11 @@ import type {
|
|||
ThinkingContent,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
TranscriptContext,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import { createAssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { getSystemMessageText } from "../utils/text.ts";
|
||||
|
||||
const DEFAULT_API = "faux";
|
||||
const DEFAULT_PROVIDER = "faux";
|
||||
|
|
@ -105,7 +106,7 @@ export interface FauxProviderState {
|
|||
}
|
||||
|
||||
export type FauxResponseFactory = (
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
state: FauxProviderState,
|
||||
model: Model<string>,
|
||||
|
|
@ -194,6 +195,15 @@ function toolResultToText(message: ToolResultMessage): string {
|
|||
}
|
||||
|
||||
function messageToText(message: Message): string {
|
||||
if (message.role === "system") {
|
||||
return [
|
||||
getSystemMessageText(message),
|
||||
...(message.toolsRemoved?.map((tool) => `tool-:${JSON.stringify(tool)}`) ?? []),
|
||||
...(message.toolsAdded?.map((tool) => `tool+:${JSON.stringify(tool)}`) ?? []),
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
if (message.role === "user") {
|
||||
return contentToText(message.content);
|
||||
}
|
||||
|
|
@ -203,18 +213,8 @@ function messageToText(message: Message): string {
|
|||
return toolResultToText(message);
|
||||
}
|
||||
|
||||
function serializeContext(context: Context): string {
|
||||
const parts: string[] = [];
|
||||
if (context.systemPrompt) {
|
||||
parts.push(`system:${context.systemPrompt}`);
|
||||
}
|
||||
for (const message of context.messages) {
|
||||
parts.push(`${message.role}:${messageToText(message)}`);
|
||||
}
|
||||
if (context.tools?.length) {
|
||||
parts.push(`tools:${JSON.stringify(context.tools)}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
function serializeContext(context: TranscriptContext): string {
|
||||
return context.messages.map((message) => `${message.role}:${messageToText(message)}`).join("\n\n");
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string): number {
|
||||
|
|
@ -228,7 +228,7 @@ function commonPrefixLength(a: string, b: string): number {
|
|||
|
||||
function withUsageEstimate(
|
||||
message: AssistantMessage,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options: StreamOptions | undefined,
|
||||
promptCache: Map<string, string>,
|
||||
): AssistantMessage {
|
||||
|
|
@ -450,7 +450,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) {
|
|||
{
|
||||
handle: DeferredHandle;
|
||||
step: FauxResponseStep;
|
||||
context: Context;
|
||||
context: TranscriptContext;
|
||||
options: SimpleStreamOptions | undefined;
|
||||
model: Model<string>;
|
||||
pendingFetches: number;
|
||||
|
|
@ -487,7 +487,7 @@ export function createFauxCore(options: RegisterFauxProviderOptions) {
|
|||
|
||||
const resolveResponse = async (
|
||||
step: FauxResponseStep,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
streamOptions: SimpleStreamOptions | undefined,
|
||||
requestModel: Model<string>,
|
||||
): Promise<AssistantMessage> => {
|
||||
|
|
|
|||
|
|
@ -270,8 +270,12 @@ export type ApiStreamOptions<TApi extends Api> = TApi extends keyof ApiOptionsMa
|
|||
* `Provider.stream()` via `ApiStreamOptions`.
|
||||
*/
|
||||
export interface ProviderStreams {
|
||||
stream(model: Model<Api>, context: Context, options?: StreamOptions): AssistantMessageEventStream;
|
||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
stream(model: Model<Api>, context: TranscriptContext, options?: StreamOptions): AssistantMessageEventStream;
|
||||
streamSimple(
|
||||
model: Model<Api>,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream;
|
||||
fetchDeferred?(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
|
|
@ -324,6 +328,8 @@ export interface SimpleStreamOptions extends StreamOptions {
|
|||
// Generic StreamFunction with typed options.
|
||||
//
|
||||
// Contract:
|
||||
// - Receives a normalized transcript: the system prompt and tools live in the
|
||||
// leading system message, never on the context itself.
|
||||
// - Must return an AssistantMessageEventStream.
|
||||
// - Direct streamSimple() calls may throw synchronously when request auth is
|
||||
// missing. Once a stream is returned, request/model/runtime failures should
|
||||
|
|
@ -332,7 +338,7 @@ export interface SimpleStreamOptions extends StreamOptions {
|
|||
// "error" or "aborted" and errorMessage, emitted via the stream protocol.
|
||||
export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> = (
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: TOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
|
|
@ -419,6 +425,34 @@ export interface DeferredHandle {
|
|||
data?: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* System instructions and tool declarations at one point in the transcript.
|
||||
*
|
||||
* The leading system message is the system prompt. Later system messages change it:
|
||||
* `content` adds instructions from that point on, `sections` replace or remove named
|
||||
* prompt sections, and `toolsAdded`/`toolsRemoved` change the tool set. Replaying
|
||||
* every system message in order yields the current prompt and tools. Providers that
|
||||
* accept system messages mid-conversation send each one in place; other providers
|
||||
* rebuild the leading system message from the replayed state.
|
||||
*/
|
||||
export interface SystemMessage {
|
||||
role: "system";
|
||||
/** Instruction text. On the leading message this is the base prompt; later, additional instructions. */
|
||||
content: string | TextContent[];
|
||||
/**
|
||||
* Named, ordered prompt sections rendered verbatim after `content`. The leading message
|
||||
* declares them; later messages replace sections by name, and `null` removes one. Keep
|
||||
* each section self-delimiting (a tag, a heading) so the model can relate an update to
|
||||
* the original. Avoid integer-like names; JSON objects reorder those.
|
||||
*/
|
||||
sections?: Record<string, string | null>;
|
||||
/** Complete definitions of tools that become available at this point. */
|
||||
toolsAdded?: Tool[];
|
||||
/** Tools that stop being available at this point. */
|
||||
toolsRemoved?: ToolReference[];
|
||||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
role: "user";
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
|
|
@ -457,17 +491,11 @@ export interface ToolResultMessage<TDetails = any> {
|
|||
details?: TDetails;
|
||||
/** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */
|
||||
usage?: Usage;
|
||||
/**
|
||||
* Names from `Context.tools` that became available after this result.
|
||||
* Providers with native deferred tool loading use this as the load point;
|
||||
* other providers ignore it and use `Context.tools` normally.
|
||||
*/
|
||||
addedToolNames?: string[];
|
||||
isError: boolean;
|
||||
timestamp: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
|
||||
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;
|
||||
|
||||
export type ImagesInputContent = TextContent | ImageContent;
|
||||
export type ImagesOutputContent = TextContent | ImageContent;
|
||||
|
|
@ -521,12 +549,35 @@ export interface Tool<TParameters extends TSchema = TSchema> {
|
|||
constrainedSampling?: false | ConstrainedSamplingConfig;
|
||||
}
|
||||
|
||||
export interface ToolReference {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request input accepted by the public stream entry points (`Models.stream()`,
|
||||
* `streamSimple()`, ...). `systemPrompt` and `tools` are shorthand for a leading
|
||||
* system message; `normalizeContext()` folds them into one before the request
|
||||
* reaches a provider.
|
||||
*/
|
||||
export interface Context {
|
||||
systemPrompt?: string;
|
||||
messages: Message[];
|
||||
tools?: Tool[];
|
||||
}
|
||||
|
||||
declare const transcriptContextBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Normalized request context passed to providers and API implementations. The
|
||||
* prompt and tool declarations are carried by the transcript's system messages.
|
||||
* Only `normalizeContext()` produces this type, so a raw `Context` cannot reach
|
||||
* provider code by accident.
|
||||
*/
|
||||
export type TranscriptContext = {
|
||||
messages: Message[];
|
||||
readonly [transcriptContextBrand]: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Event protocol for AssistantMessageEventStream.
|
||||
*
|
||||
|
|
@ -621,14 +672,16 @@ export interface OpenAICompletionsCompat {
|
|||
supportsThinkingTokenBudget?: boolean;
|
||||
/** Whether the provider supports OpenAI custom tools with Lark/regex grammar formats. When false, grammar-constrained tools fall back to normal function tools. Default: false; the generated model catalog enables it for capable models. */
|
||||
supportsOpenAIGrammarTools?: boolean;
|
||||
/** Whether the exact model accepts system or developer messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false; the generated model catalog enables it for verified models. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
/** Whether system messages can introduce additional tools mid-conversation. Requires `supportsMidConvoSystemMessages`. Default: false; the generated model catalog enables it for capable models. */
|
||||
supportsMidConvoToolAdditions?: boolean;
|
||||
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
|
||||
supportsStrictMode?: boolean;
|
||||
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */
|
||||
cacheControlFormat?: "anthropic";
|
||||
/** Whether to send session-affinity data from `options.sessionId`. Default: true for OpenRouter endpoints, false otherwise. */
|
||||
sendSessionAffinityHeaders?: boolean;
|
||||
/** Provider-specific deferred tool serialization mode. */
|
||||
deferredToolsMode?: "kimi";
|
||||
/** Session-affinity header format: `openai` sends `session_id`, `x-client-request-id`, and `x-session-affinity`; `openai-nosession` sends `x-client-request-id` and `x-session-affinity`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */
|
||||
sessionAffinityFormat?: SessionAffinityFormat;
|
||||
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
|
||||
|
|
@ -646,6 +699,8 @@ export interface OpenAICompletionsCompat {
|
|||
export interface OpenAIResponsesCompat {
|
||||
/** Whether the provider supports the `developer` role (vs `system`). Default: true. */
|
||||
supportsDeveloperRole?: boolean;
|
||||
/** Whether the exact model accepts developer or system messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false; the generated model catalog enables it for verified models. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
/** Session-affinity header format: `openai` sends `session_id` and `x-client-request-id`; `openai-nosession` sends `x-client-request-id`; `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param, which is governed by cache retention. Default: auto-detected. */
|
||||
sessionAffinityFormat?: SessionAffinityFormat;
|
||||
/** Whether the provider supports long prompt cache retention. This uses `prompt_cache_options.ttl: "30m"` on GPT-5.6+ and `prompt_cache_retention: "24h"` on earlier models. Default: true. */
|
||||
|
|
@ -656,7 +711,7 @@ export interface OpenAIResponsesCompat {
|
|||
supportsOpenAIGrammarTools?: boolean;
|
||||
/** Whether the model supports message-anchored `additional_tools` input items. Default: false. */
|
||||
supportsAdditionalTools?: boolean;
|
||||
/** Whether the model supports client-executed tool search for deferred tools. Default: false. */
|
||||
/** Whether the model supports client-executed tool search for transcript-anchored additions. Default: false. */
|
||||
supportsToolSearch?: boolean;
|
||||
/** Whether the model accepts `prompt_cache_options` (OpenAI GPT-5.6+ prompt caching). Older OpenAI models reject the parameter. Default: false. */
|
||||
supportsExplicitPromptCacheMode?: boolean;
|
||||
|
|
@ -716,6 +771,10 @@ export interface AnthropicMessagesCompat {
|
|||
supportsStrictTools?: boolean;
|
||||
/** Whether the exact model transport supports effort-only system messages and thinking binding controls. Default: false. */
|
||||
supportsMidConvoEffort?: boolean;
|
||||
/** Whether the exact model accepts system-role messages inside the conversation. When false, later system messages are folded into the top-level system prompt. Default: false. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
/** Whether the exact model accepts mid-conversation `tool_addition` and `tool_removal` blocks. Requires `supportsMidConvoSystemMessages`. Default: false. */
|
||||
supportsMidConvoToolChanges?: boolean;
|
||||
/**
|
||||
* Models Anthropic accepts in `fallbacks` for server-side refusal fallback,
|
||||
* with local pricing metadata for returned fallback responses. When absent or
|
||||
|
|
@ -723,12 +782,6 @@ export interface AnthropicMessagesCompat {
|
|||
* with no permitted fallback targets.
|
||||
*/
|
||||
allowedFallbackModels?: AnthropicAllowedFallbackModel[];
|
||||
/**
|
||||
* Whether the provider supports deferred tools loaded by `tool_reference`
|
||||
* blocks in tool results. Default: true for first-party Anthropic models
|
||||
* except Haiku and models older than Claude 4.5; false for other providers.
|
||||
*/
|
||||
supportsToolReferences?: boolean;
|
||||
}
|
||||
|
||||
/** Compatibility settings for Amazon Bedrock models. */
|
||||
|
|
@ -737,6 +790,12 @@ export interface BedrockCompat {
|
|||
supportsStrictMode?: boolean;
|
||||
}
|
||||
|
||||
/** Compatibility settings for the Mistral chat API. */
|
||||
export interface MistralConversationsCompat {
|
||||
/** Whether the exact model accepts system messages after the conversation has started. When false, later system messages are folded into the leading system message. Default: false. */
|
||||
supportsMidConvoSystemMessages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter provider routing preferences.
|
||||
* Controls which upstream providers OpenRouter routes requests to.
|
||||
|
|
@ -870,7 +929,9 @@ export interface Model<TApi extends Api> {
|
|||
? AnthropicMessagesCompat
|
||||
: TApi extends "bedrock-converse-stream"
|
||||
? BedrockCompat
|
||||
: never;
|
||||
: TApi extends "mistral-conversations"
|
||||
? MistralConversationsCompat
|
||||
: never;
|
||||
}
|
||||
|
||||
export interface ImagesModel<TApi extends ImagesApi>
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import type { Context, Tool } from "../types.ts";
|
||||
|
||||
type ToolNameNormalizer = (name: string) => string;
|
||||
|
||||
const identityToolName: ToolNameNormalizer = (name) => name;
|
||||
|
||||
/** Split current tools into prefix and transcript-loaded definitions. */
|
||||
export function splitDeferredTools(
|
||||
context: Context,
|
||||
enabled: boolean,
|
||||
normalizeName: ToolNameNormalizer = identityToolName,
|
||||
): { immediate: Tool[]; deferred: Map<string, Tool> } {
|
||||
const uniqueTools = new Map<string, Tool>();
|
||||
for (const tool of context.tools ?? []) uniqueTools.set(normalizeName(tool.name), tool);
|
||||
if (!enabled) return { immediate: [...uniqueTools.values()], deferred: new Map() };
|
||||
|
||||
const deferredNames = new Set<string>();
|
||||
const usedNames = new Set<string>();
|
||||
for (const message of context.messages) {
|
||||
if (message.role === "assistant") {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolCall") usedNames.add(normalizeName(block.name));
|
||||
}
|
||||
} else if (message.role === "toolResult") {
|
||||
for (const name of message.addedToolNames ?? []) {
|
||||
const normalizedName = normalizeName(name);
|
||||
if (!usedNames.has(normalizedName)) deferredNames.add(normalizedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const immediate: Tool[] = [];
|
||||
const deferred = new Map<string, Tool>();
|
||||
for (const [name, tool] of uniqueTools) {
|
||||
if (deferredNames.has(name)) deferred.set(name, tool);
|
||||
else immediate.push(tool);
|
||||
}
|
||||
return { immediate, deferred };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AssistantMessage, Context, ImageContent, Message, TextContent, Tool, Usage } from "../types.ts";
|
||||
import type { AssistantMessage, ImageContent, Message, TextContent, TranscriptContext, Usage } from "../types.ts";
|
||||
import { getSystemMessageText } from "./text.ts";
|
||||
|
||||
export interface ContextUsageEstimate {
|
||||
/** Estimated total context tokens. */
|
||||
|
|
@ -45,6 +46,13 @@ export function estimateTextAndImageContentTokens(content: string | Array<TextCo
|
|||
export function estimateMessageTokens(message: Message): number {
|
||||
let chars = 0;
|
||||
|
||||
if (message.role === "system") {
|
||||
return (
|
||||
estimateTextTokens(getSystemMessageText(message)) +
|
||||
estimateToolsTokens(message.toolsAdded) +
|
||||
estimateToolsTokens(message.toolsRemoved)
|
||||
);
|
||||
}
|
||||
if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
|
||||
if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
|
||||
|
||||
|
|
@ -86,7 +94,8 @@ function getLastAssistantUsageInfo(messages: readonly Message[]): { usage: Usage
|
|||
return usageInfo;
|
||||
}
|
||||
|
||||
function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
|
||||
export function estimateContextTokens(context: TranscriptContext | readonly Message[]): ContextUsageEstimate {
|
||||
const messages = "messages" in context ? context.messages : context;
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
if (usageInfo) {
|
||||
const usageTokens = calculateContextTokens(usageInfo.usage);
|
||||
|
|
@ -102,42 +111,7 @@ function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
|
|||
return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
|
||||
}
|
||||
|
||||
function estimateToolsTokens(tools: readonly Tool[] | undefined): number {
|
||||
function estimateToolsTokens(tools: readonly unknown[] | undefined): number {
|
||||
if (!tools || tools.length === 0) return 0;
|
||||
return estimateTextTokens(safeJsonStringify(tools));
|
||||
}
|
||||
|
||||
function isMessageArray(value: Context | readonly Message[]): value is readonly Message[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
export function estimateContextTokens(context: Context | readonly Message[]): ContextUsageEstimate {
|
||||
if (isMessageArray(context)) return estimateMessages(context);
|
||||
|
||||
const estimate = estimateMessages(context.messages);
|
||||
if (estimate.lastUsageIndex !== null) {
|
||||
const addedNames = new Set(
|
||||
context.messages
|
||||
.slice(estimate.lastUsageIndex + 1)
|
||||
.filter((message) => message.role === "toolResult")
|
||||
.flatMap((message) => message.addedToolNames ?? []),
|
||||
);
|
||||
const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name)));
|
||||
return {
|
||||
tokens: estimate.tokens + addedToolTokens,
|
||||
usageTokens: estimate.usageTokens,
|
||||
trailingTokens: estimate.trailingTokens + addedToolTokens,
|
||||
lastUsageIndex: estimate.lastUsageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const prefixTokens =
|
||||
(context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools);
|
||||
|
||||
return {
|
||||
tokens: estimate.tokens + prefixTokens,
|
||||
usageTokens: estimate.usageTokens,
|
||||
trailingTokens: estimate.trailingTokens + prefixTokens,
|
||||
lastUsageIndex: estimate.lastUsageIndex,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts";
|
||||
import type { ImageContent, SystemMessage, TextContent, ThinkingContent, ToolCall } from "../types.ts";
|
||||
|
||||
type Content = TextContent | ImageContent | ThinkingContent | ToolCall;
|
||||
|
||||
|
|
@ -10,3 +10,31 @@ export function contentText(content: string | readonly Content[], separator = "\
|
|||
.map((block) => block.text)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
/** Render a system message as a complete prompt: its content followed by its sections. */
|
||||
export function getSystemMessageText(message: SystemMessage): string {
|
||||
const parts = [contentText(message.content)];
|
||||
for (const text of Object.values(message.sections ?? {})) {
|
||||
if (text !== null) parts.push(text);
|
||||
}
|
||||
return parts.filter((part) => part.length > 0).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a later system message for APIs that accept system messages mid-conversation.
|
||||
* Section changes are framed by name so the model can relate them to the leading prompt.
|
||||
* This framing is request-time only and may change between versions.
|
||||
*/
|
||||
export function renderSystemMessageUpdate(message: SystemMessage): string {
|
||||
const parts: string[] = [];
|
||||
const text = contentText(message.content);
|
||||
if (text.length > 0) parts.push(text);
|
||||
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
||||
parts.push(
|
||||
value === null
|
||||
? `Removed system prompt section "${name}".`
|
||||
: `Updated system prompt section "${name}":\n\n${value}`,
|
||||
);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
|
|
|||
234
packages/ai/src/utils/transcript.ts
Normal file
234
packages/ai/src/utils/transcript.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import type { Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext } from "../types.ts";
|
||||
import { contentText, getSystemMessageText } from "./text.ts";
|
||||
|
||||
export type { TranscriptContext } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Build the leading system message for a prompt and tool set. Returns undefined when
|
||||
* both are empty, so an empty transcript stays empty.
|
||||
*/
|
||||
export function createInitialSystemMessage(
|
||||
systemPrompt: string | undefined,
|
||||
tools: Tool[] | undefined,
|
||||
): SystemMessage | undefined {
|
||||
const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
|
||||
const hasTools = tools !== undefined && tools.length > 0;
|
||||
if (!hasSystemPrompt && !hasTools) return undefined;
|
||||
return {
|
||||
role: "system",
|
||||
content: systemPrompt ?? "",
|
||||
...(hasTools ? { toolsAdded: tools } : {}),
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `Context.systemPrompt` and `Context.tools` into a leading system message.
|
||||
* This is the only entry point that produces a {@link TranscriptContext}; every
|
||||
* provider-facing function expects the result.
|
||||
*/
|
||||
export function normalizeContext(context: Context): TranscriptContext {
|
||||
const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
|
||||
const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages;
|
||||
return { messages } as TranscriptContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any message list. The replay helpers only read entries whose role is `"system"`, so
|
||||
* agent transcripts that carry custom message roles can be passed without filtering.
|
||||
*/
|
||||
export type TranscriptMessages = readonly { role: string }[];
|
||||
|
||||
function isSystemMessage(message: { role: string }): message is SystemMessage {
|
||||
return message.role === "system";
|
||||
}
|
||||
|
||||
/** Return the leading system message, if the transcript starts with one. */
|
||||
export function getInitialSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
|
||||
const first = messages[0];
|
||||
return first && isSystemMessage(first) ? first : undefined;
|
||||
}
|
||||
|
||||
/** Drop the leading system message for APIs that carry the prompt outside the message list. */
|
||||
export function withoutInitialSystemMessage(messages: Message[]): Message[] {
|
||||
return getInitialSystemMessage(messages) ? messages.slice(1) : messages;
|
||||
}
|
||||
|
||||
/** Resolve the tools available after applying every transcript delta in order. */
|
||||
export function getCurrentTools(messages: TranscriptMessages): Tool[] {
|
||||
const tools = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsRemoved ?? []) tools.delete(tool.name);
|
||||
for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool);
|
||||
}
|
||||
return [...tools.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every system message into one leading system message holding the current
|
||||
* prompt and tools. Later `content` is appended to the base prompt, `sections` are
|
||||
* patched by name, and tools are resolved with {@link getCurrentTools}.
|
||||
*/
|
||||
export function getCurrentSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
|
||||
const content: string[] = [];
|
||||
const sections = new Map<string, string>();
|
||||
let timestamp: number | undefined;
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
timestamp ??= message.timestamp;
|
||||
const text = contentText(message.content);
|
||||
if (text.length > 0) content.push(text);
|
||||
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
||||
if (value === null) sections.delete(name);
|
||||
else sections.set(name, value);
|
||||
}
|
||||
}
|
||||
const tools = getCurrentTools(messages);
|
||||
if (timestamp === undefined && tools.length === 0) return undefined;
|
||||
return {
|
||||
role: "system",
|
||||
content: content.join("\n\n"),
|
||||
...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
|
||||
...(tools.length > 0 ? { toolsAdded: tools } : {}),
|
||||
timestamp: timestamp ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the current system prompt text after replaying every system message. */
|
||||
export function getCurrentSystemPrompt(messages: TranscriptMessages): string {
|
||||
const message = getCurrentSystemMessage(messages);
|
||||
return message ? getSystemMessageText(message) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the transcript for APIs without mid-conversation system messages: the replayed
|
||||
* system message leads, and every later system message is dropped.
|
||||
*/
|
||||
export function collapseSystemMessages(context: TranscriptContext): TranscriptContext {
|
||||
const head = getCurrentSystemMessage(context.messages);
|
||||
const messages = context.messages.filter((message) => message.role !== "system");
|
||||
return { messages: head ? [head, ...messages] : messages } as TranscriptContext;
|
||||
}
|
||||
|
||||
/** Keep later system messages in place when the model accepts them; otherwise collapse them. */
|
||||
export function resolveTranscript(
|
||||
context: TranscriptContext,
|
||||
supportsMidConvoSystemMessages: boolean | undefined,
|
||||
): TranscriptContext {
|
||||
return supportsMidConvoSystemMessages ? context : collapseSystemMessages(context);
|
||||
}
|
||||
|
||||
/** Strip executable and display-only fields from a tool before transcript comparison or persistence. */
|
||||
export function toToolDeclaration(tool: Tool): Tool {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"],
|
||||
...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two tools declare the same interface to the model.
|
||||
*
|
||||
* Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
|
||||
* typebox symbol keys and `undefined` fields that a structural comparison would see, and
|
||||
* builds both objects with the same key order, so comparing the serialized declarations
|
||||
* is exact. This avoids a deep-equal dependency in a browser-safe package.
|
||||
*/
|
||||
export function declarationsEqual(left: Tool, right: Tool): boolean {
|
||||
return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right));
|
||||
}
|
||||
|
||||
export interface ToolStateChanges {
|
||||
toolsAdded: Tool[];
|
||||
toolsRemoved: ToolReference[];
|
||||
}
|
||||
|
||||
/** Compare two complete tool states. A changed definition is a removal followed by an addition. */
|
||||
export function getToolStateChanges(previous: readonly Tool[], current: readonly Tool[]): ToolStateChanges {
|
||||
const previousTools = new Map(previous.map((tool) => [tool.name, tool]));
|
||||
const currentTools = new Map(current.map((tool) => [tool.name, tool]));
|
||||
return {
|
||||
toolsAdded: current
|
||||
.filter((tool) => {
|
||||
const previousTool = previousTools.get(tool.name);
|
||||
return previousTool === undefined || !declarationsEqual(previousTool, tool);
|
||||
})
|
||||
.map(toToolDeclaration),
|
||||
toolsRemoved: previous
|
||||
.filter((tool) => {
|
||||
const currentTool = currentTools.get(tool.name);
|
||||
return currentTool === undefined || !declarationsEqual(tool, currentTool);
|
||||
})
|
||||
.map((tool) => ({ name: tool.name })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every definition referenced by transcript tool state, in first-declaration order. */
|
||||
export function getDeclaredTools(messages: TranscriptMessages): Tool[] {
|
||||
const definitions = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsAdded ?? []) definitions.set(tool.name, tool);
|
||||
}
|
||||
return [...definitions.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool name was declared twice with different definitions. Transports that
|
||||
* reference tools by name (Anthropic `tool_addition`/`tool_removal`) cannot express that.
|
||||
*/
|
||||
export function hasToolRedefinitions(messages: TranscriptMessages): boolean {
|
||||
const declared = new Map<string, Tool>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
for (const tool of message.toolsAdded ?? []) {
|
||||
const previous = declared.get(tool.name);
|
||||
if (previous !== undefined && !declarationsEqual(previous, tool)) return true;
|
||||
declared.set(tool.name, tool);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether tool history contains a removal or same-name redeclaration that an addition-only transport cannot replay. */
|
||||
export function hasNonAdditiveToolChanges(messages: TranscriptMessages): boolean {
|
||||
const declared = new Set<string>();
|
||||
for (const message of messages) {
|
||||
if (!isSystemMessage(message)) continue;
|
||||
if ((message.toolsRemoved?.length ?? 0) > 0) return true;
|
||||
for (const tool of message.toolsAdded ?? []) {
|
||||
if (declared.has(tool.name)) return true;
|
||||
declared.add(tool.name);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface TranscriptTools {
|
||||
/** Tools sent in the top-level request field. */
|
||||
requestTools: Tool[];
|
||||
/**
|
||||
* Whether later system messages carry their own `toolsAdded` as in-place additions.
|
||||
* When false, `requestTools` already holds the complete current tool set.
|
||||
*/
|
||||
anchorsAdditions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split tool declarations between the top-level request field and in-place additions.
|
||||
* Transports that can anchor additions at a system message keep the initial tools at the
|
||||
* top and load later ones where they appear; that only works when no tool was removed or
|
||||
* redeclared, so everything else sends the current tool list.
|
||||
*/
|
||||
export function resolveTranscriptTools(messages: TranscriptMessages, supportsToolAdditions: boolean): TranscriptTools {
|
||||
const anchorsAdditions = supportsToolAdditions && !hasNonAdditiveToolChanges(messages);
|
||||
return {
|
||||
requestTools: anchorsAdditions
|
||||
? (getInitialSystemMessage(messages)?.toolsAdded ?? [])
|
||||
: getCurrentTools(messages),
|
||||
anchorsAdditions,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
|||
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
constructorOpts: undefined as Record<string, unknown> | undefined,
|
||||
|
|
@ -57,10 +58,10 @@ vi.mock("@anthropic-ai/sdk", () => {
|
|||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "System prompt.",
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const anthropicModel: Model<"anthropic-messages"> = {
|
||||
id: "claude-test",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
|
||||
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
|
||||
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
|
||||
|
|
@ -57,7 +56,7 @@ function eventsWithCacheCreation(
|
|||
}
|
||||
|
||||
// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10.
|
||||
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] });
|
||||
|
||||
describe("Anthropic 1h cache write cost", () => {
|
||||
it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => {
|
||||
|
|
@ -65,7 +64,9 @@ describe("Anthropic 1h cache write cost", () => {
|
|||
const response = createSseResponse(
|
||||
eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }),
|
||||
);
|
||||
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
||||
const result = await streamAnthropic(model, context, {
|
||||
client: createFakeAnthropicClient(response),
|
||||
}).result();
|
||||
|
||||
expect(result.usage.cacheWrite).toBe(1_000_000);
|
||||
expect(result.usage.cacheWrite1h).toBe(400_000);
|
||||
|
|
@ -76,7 +77,9 @@ describe("Anthropic 1h cache write cost", () => {
|
|||
it("falls back to the 5m rate when no breakdown is reported", async () => {
|
||||
const model = getModel("anthropic", "claude-opus-4-8");
|
||||
const response = createSseResponse(eventsWithCacheCreation(undefined));
|
||||
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
||||
const result = await streamAnthropic(model, context, {
|
||||
client: createFakeAnthropicClient(response),
|
||||
}).result();
|
||||
|
||||
expect(result.usage.cacheWrite).toBe(1_000_000);
|
||||
expect(result.usage.cacheWrite1h ?? 0).toBe(0);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Type } from "typebox";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import type { Context, Model, Tool } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
interface CapturedRequest {
|
||||
headers: IncomingMessage["headers"];
|
||||
|
|
@ -84,10 +85,14 @@ async function captureAnthropicRequest(
|
|||
const address = server.address() as AddressInfo;
|
||||
|
||||
try {
|
||||
const stream = streamAnthropic(createModel(`http://127.0.0.1:${address.port}`, compat), context, {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
});
|
||||
const stream = streamAnthropic(
|
||||
createModel(`http://127.0.0.1:${address.port}`, compat),
|
||||
normalizeContext(context),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
},
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === "done" || event.type === "error") break;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stream } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
||||
|
||||
interface WireMessage {
|
||||
|
|
@ -67,7 +67,7 @@ async function capture(
|
|||
effort?: "low" | "medium" | "high" | "xhigh" | "max",
|
||||
): Promise<{ payload: CapturedPayload; message: AssistantMessage }> {
|
||||
let payload: CapturedPayload | undefined;
|
||||
const result = stream(model, context, {
|
||||
const result = stream(model, normalizeContext(context), {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
thinkingEnabled: true,
|
||||
|
|
@ -182,15 +182,11 @@ describe("Anthropic mid-conversation effort", () => {
|
|||
betaHeader = request.headers.get("anthropic-beta");
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
};
|
||||
const result = await stream(
|
||||
managedModel(),
|
||||
{ messages: [user("one", 1)] },
|
||||
{
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
fetch: fetchImpl,
|
||||
},
|
||||
).result();
|
||||
const result = await stream(managedModel(), normalizeContext({ messages: [user("one", 1)] }), {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
fetch: fetchImpl,
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(betaHeader).toContain("mid-conversation-output-config-2026-07-01");
|
||||
|
|
@ -207,6 +203,8 @@ describe("Anthropic mid-conversation effort", () => {
|
|||
expect(openRouter.baseUrl).toBe("https://openrouter.ai/api");
|
||||
expect(openRouter.compat?.supportsMidConvoEffort).toBe(true);
|
||||
expect(unsupported.compat?.supportsMidConvoEffort).toBeUndefined();
|
||||
// OpenRouter rejects configuration_update on Opus 5 but accepts it on Fable 5.1.
|
||||
expect(getModel("openrouter", "anthropic/claude-opus-5").compat?.supportsMidConvoEffort).toBeUndefined();
|
||||
expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import type Anthropic from "@anthropic-ai/sdk";
|
|||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, ToolCall } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { ToolCall } from "../src/types.ts";
|
||||
|
||||
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
|
||||
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
|
||||
|
|
@ -120,7 +120,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
const result = await streamAnthropic(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{ client: createFakeAnthropicClient(response) },
|
||||
).result();
|
||||
|
||||
|
|
@ -143,7 +143,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{
|
||||
client,
|
||||
onPayload: (payload) => ({ ...(payload as Record<string, unknown>), stream: false }),
|
||||
|
|
@ -168,7 +168,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
await streamAnthropic(
|
||||
getModel("openrouter", "anthropic/claude-3-haiku"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{ client, thinkingEnabled: false },
|
||||
).result();
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
const result = await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{ client },
|
||||
).result();
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
const result = await streamAnthropic(
|
||||
getModel("anthropic", "claude-fable-5-1"),
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Hello", timestamp: 1 }] }),
|
||||
{ client: createFakeAnthropicClient(createSseResponse(events)) },
|
||||
).result();
|
||||
|
||||
|
|
@ -242,7 +242,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
});
|
||||
it("repairs malformed SSE JSON and malformed streamed tool JSON", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "Use the edit tool.", timestamp: Date.now() }],
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -254,7 +254,7 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const malformedToolJsonDelta = String.raw`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"A\H\",\"text\":\"col1 col2\"}"}}`;
|
||||
|
||||
|
|
@ -329,9 +329,9 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
it("preserves content from content_block_start events", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const response = createSseResponse([
|
||||
{
|
||||
event: "message_start",
|
||||
|
|
@ -427,9 +427,9 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
it("preserves refusal stop details from message_delta", async () => {
|
||||
const model = getModel("anthropic", "claude-fable-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const explanation =
|
||||
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
|
||||
const response = createSseResponse([
|
||||
|
|
@ -486,9 +486,9 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
it("preserves sensitive stop reasons with a descriptive error message", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const response = createSseResponse([
|
||||
{
|
||||
event: "message_start",
|
||||
|
|
@ -536,9 +536,9 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
it("treats message_delta without usage as a no-op for usage accumulation", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const response = createSseResponse(
|
||||
minimalAnthropicEvents.map((event) =>
|
||||
event.event === "message_delta"
|
||||
|
|
@ -564,9 +564,9 @@ describe("Anthropic raw SSE parsing", () => {
|
|||
|
||||
it("ignores unknown SSE events after message_stop", async () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const response = createSseResponse([
|
||||
...minimalAnthropicEvents,
|
||||
{ event: "done", data: "[DONE]" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stream } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context } from "../src/types.ts";
|
||||
|
||||
const enabled = Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
|
|
@ -20,7 +20,7 @@ function strictBinding(payload: unknown): unknown {
|
|||
}
|
||||
|
||||
async function request(context: Context, effort: "low" | "high"): Promise<AssistantMessage> {
|
||||
return stream(model, context, {
|
||||
return stream(model, normalizeContext(context), {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
cacheRetention: "none",
|
||||
maxTokens: 1536,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { arch, platform, release } from "node:os";
|
|||
import { Type } from "typebox";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
|
||||
interface CapturedAzureClientOptions {
|
||||
|
|
@ -90,14 +90,14 @@ afterEach(() => {
|
|||
async function captureClientBaseUrl(baseUrl: string): Promise<string> {
|
||||
process.env.AZURE_OPENAI_BASE_URL = baseUrl;
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
||||
await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key" }).result();
|
||||
expect(azureMock.constructorCalls).toHaveLength(1);
|
||||
return azureMock.constructorCalls[0].baseURL;
|
||||
}
|
||||
|
||||
async function captureClientHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
await streamAzureOpenAIResponses(model, context, {
|
||||
await streamAzureOpenAIResponses(model, normalizeContext(context), {
|
||||
apiKey: "test-api-key",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
headers,
|
||||
|
|
@ -155,14 +155,16 @@ describe("azure-openai-responses base URL normalization", () => {
|
|||
it("throws on invalid URLs", async () => {
|
||||
process.env.AZURE_OPENAI_BASE_URL = "not-a-url";
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
const result = await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
||||
const result = await streamAzureOpenAIResponses(model, normalizeContext(context), {
|
||||
apiKey: "test-api-key",
|
||||
}).result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL");
|
||||
});
|
||||
|
||||
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
await streamAzureOpenAIResponses(model, context, {
|
||||
await streamAzureOpenAIResponses(model, normalizeContext(context), {
|
||||
apiKey: "test-api-key",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
sessionId: "x".repeat(67),
|
||||
|
|
@ -173,7 +175,7 @@ describe("azure-openai-responses base URL normalization", () => {
|
|||
|
||||
it("disables server-side response storage", async () => {
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
await streamAzureOpenAIResponses(model, context, {
|
||||
await streamAzureOpenAIResponses(model, normalizeContext(context), {
|
||||
apiKey: "test-api-key",
|
||||
azureBaseUrl: "https://my-resource.openai.azure.com",
|
||||
}).result();
|
||||
|
|
@ -190,7 +192,7 @@ describe("azure-openai-responses base URL normalization", () => {
|
|||
|
||||
await streamAzureOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
...context,
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -200,7 +202,7 @@ describe("azure-openai-responses base URL normalization", () => {
|
|||
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com" },
|
||||
).result();
|
||||
|
||||
|
|
@ -210,7 +212,7 @@ describe("azure-openai-responses base URL normalization", () => {
|
|||
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
|
||||
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
|
||||
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
||||
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
||||
await streamAzureOpenAIResponses(model, normalizeContext(context), { apiKey: "test-api-key" }).result();
|
||||
expect(azureMock.constructorCalls).toHaveLength(1);
|
||||
expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { ResponseReasoningItem, ResponseStreamEvent } from "openai/resources/responses/responses.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertResponsesMessages, processResponsesStream } from "../src/api/openai-responses-shared.ts";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
||||
import type { AssistantMessage, Model } from "../src/types.ts";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
function createModel(): Model<"azure-openai-responses"> {
|
||||
return {
|
||||
|
|
@ -67,13 +68,13 @@ async function* createEvents(
|
|||
}
|
||||
|
||||
function getReplayedReasoning(model: Model<"azure-openai-responses">, assistant: AssistantMessage) {
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "first", timestamp: Date.now() - 1 },
|
||||
assistant,
|
||||
{ role: "user", content: "follow-up", timestamp: Date.now() },
|
||||
],
|
||||
};
|
||||
});
|
||||
const input = convertResponsesMessages(model, context, new Set(["azure-openai-responses"]));
|
||||
return input.find((item) => item.type === "reasoning");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stream, streamSimple } from "../src/api/azure-openai-responses.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const model: Model<"azure-openai-responses"> = {
|
||||
id: "test-deployment",
|
||||
|
|
@ -21,7 +22,7 @@ describe("Azure OpenAI tool choice", () => {
|
|||
let payload: unknown;
|
||||
const result = stream(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -30,7 +31,7 @@ describe("Azure OpenAI tool choice", () => {
|
|||
parameters: Type.Object({ path: Type.String() }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
toolChoice: "required",
|
||||
|
|
@ -51,7 +52,7 @@ describe("Azure OpenAI tool choice", () => {
|
|||
let payload: unknown;
|
||||
const result = streamSimple(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -60,7 +61,7 @@ describe("Azure OpenAI tool choice", () => {
|
|||
parameters: Type.Object({ path: Type.String() }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
toolChoice: "none",
|
||||
|
|
|
|||
|
|
@ -59,11 +59,10 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
});
|
||||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
|
||||
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
||||
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "hi", timestamp: Date.now() }] });
|
||||
|
||||
describe("Bedrock 1h cache write cost", () => {
|
||||
it("prices the 1h cache details at 2x while preserving the total cache write", async () => {
|
||||
|
|
@ -72,7 +71,7 @@ describe("Bedrock 1h cache write cost", () => {
|
|||
|
||||
expect(result.usage.cacheWrite).toBe(1_000_000);
|
||||
expect(result.usage.cacheWrite1h).toBe(400_000);
|
||||
// 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75
|
||||
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
|
||||
const expectedCacheWriteCost = (600_000 * model.cost.cacheWrite + 400_000 * model.cost.input * 2) / 1_000_000;
|
||||
expect(result.usage.cost.cacheWrite).toBeCloseTo(expectedCacheWriteCost, 10);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import type { Context, Message, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const baseModel: Model<"bedrock-converse-stream"> = {
|
||||
id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
|
|
@ -82,7 +83,7 @@ const novaModel: Model<"bedrock-converse-stream"> = {
|
|||
|
||||
async function capturePayload(context: Context, model = baseModel): Promise<unknown> {
|
||||
let capturedPayload: unknown;
|
||||
const s = streamBedrock(model, context, {
|
||||
const s = streamBedrock(model, normalizeContext(context), {
|
||||
cacheRetention: "none",
|
||||
signal: AbortSignal.abort(),
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -151,7 +152,7 @@ describe("Bedrock tool arguments", () => {
|
|||
try {
|
||||
const message = await streamBedrock(
|
||||
baseModel,
|
||||
{ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "Use the tool", timestamp: Date.now() }] }),
|
||||
{ cacheRetention: "none" },
|
||||
).result();
|
||||
|
||||
|
|
|
|||
|
|
@ -53,12 +53,12 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
|
||||
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { stream as streamBedrock, streamSimple as streamSimpleBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const MIDDLEWARE_NAME = "pi-ai-custom-headers";
|
||||
|
||||
|
|
|
|||
|
|
@ -49,17 +49,17 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
});
|
||||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Model } from "../src/types.ts";
|
||||
import type { AssistantMessageDiagnostic } from "../src/utils/diagnostics.ts";
|
||||
|
||||
const DIAGNOSTIC_TYPE = "bedrock_response_failure";
|
||||
const VALIDATION_MESSAGE = "The provided model identifier is invalid.";
|
||||
const REQUEST_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
function getModelFixture(): Model<"bedrock-converse-stream"> {
|
||||
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
||||
|
|
|
|||
|
|
@ -51,13 +51,12 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
});
|
||||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
|
||||
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
describe("Bedrock raw stop reasons", () => {
|
||||
it("preserves raw Bedrock stop reasons for successful stops", async () => {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
|||
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import type { Context, Message, Model, ThinkingContent } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const gptModel: Model<"bedrock-converse-stream"> = {
|
||||
id: "global.openai.gpt-5.6-terra",
|
||||
|
|
@ -103,13 +104,15 @@ function redactedReasoningEvents(): unknown[] {
|
|||
];
|
||||
}
|
||||
|
||||
const helloContext = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] });
|
||||
|
||||
interface BedrockRequestPayload {
|
||||
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||
}
|
||||
|
||||
async function capturePayload(context: Context): Promise<BedrockRequestPayload> {
|
||||
let capturedPayload: BedrockRequestPayload | undefined;
|
||||
const s = streamBedrock(gptModel, context, {
|
||||
const s = streamBedrock(gptModel, normalizeContext(context), {
|
||||
cacheRetention: "none",
|
||||
signal: AbortSignal.abort(),
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -134,9 +137,7 @@ describe("Bedrock redacted reasoning", () => {
|
|||
it("does not fail the stream when reasoning arrives as redactedContent", async () => {
|
||||
bedrockMock.streamEvents = redactedReasoningEvents();
|
||||
|
||||
const response = await streamBedrock(gptModel, {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
}).result();
|
||||
const response = await streamBedrock(gptModel, helloContext).result();
|
||||
|
||||
expect(response.stopReason, response.errorMessage).not.toBe("error");
|
||||
// Reasoning precedes the answer, matching the order Bedrock streamed it.
|
||||
|
|
@ -147,9 +148,7 @@ describe("Bedrock redacted reasoning", () => {
|
|||
it("preserves the encrypted reasoning payload on the assistant message", async () => {
|
||||
bedrockMock.streamEvents = redactedReasoningEvents();
|
||||
|
||||
const response = await streamBedrock(gptModel, {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
}).result();
|
||||
const response = await streamBedrock(gptModel, helloContext).result();
|
||||
|
||||
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
||||
expect(thinking).toBeDefined();
|
||||
|
|
@ -174,9 +173,7 @@ describe("Bedrock redacted reasoning", () => {
|
|||
{ messageStop: { stopReason: "end_turn" } },
|
||||
];
|
||||
|
||||
const response = await streamBedrock(gptModel, {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
}).result();
|
||||
const response = await streamBedrock(gptModel, helloContext).result();
|
||||
|
||||
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
||||
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
|
||||
|
|
@ -195,9 +192,7 @@ describe("Bedrock redacted reasoning", () => {
|
|||
{ messageStop: { stopReason: "end_turn" } },
|
||||
];
|
||||
|
||||
const response = await streamBedrock(gptModel, {
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
}).result();
|
||||
const response = await streamBedrock(gptModel, helloContext).result();
|
||||
|
||||
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
||||
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model, ProviderResponse } from "../src/types.ts";
|
||||
|
||||
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
|
||||
|
|
@ -41,7 +41,7 @@ describe("bedrock response headers", () => {
|
|||
|
||||
const result = await streamBedrock(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }),
|
||||
{
|
||||
cacheRetention: "none",
|
||||
env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ async function capturePayload(
|
|||
options?: BedrockOptions,
|
||||
): Promise<BedrockThinkingPayload> {
|
||||
let capturedPayload: BedrockThinkingPayload | undefined;
|
||||
const s = streamBedrock(model, makeContext(), {
|
||||
const s = streamBedrock(model, normalizeContext(makeContext()), {
|
||||
...options,
|
||||
reasoning: options?.reasoning ?? "high",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -175,7 +175,7 @@ describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () =>
|
|||
|
||||
const response = await streamBedrock(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.",
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -185,7 +185,7 @@ describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () =>
|
|||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ reasoning: "low" },
|
||||
).result();
|
||||
|
||||
|
|
@ -221,10 +221,10 @@ describe("Application inference profile support", () => {
|
|||
let capturedPayload: any;
|
||||
const s = streamBedrock(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "You are helpful.",
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import { getModel, stream } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext, stream } from "../src/compat.ts";
|
||||
import { MODELS } from "../src/models.generated.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
class PayloadCaptured extends Error {
|
||||
constructor() {
|
||||
|
|
@ -44,10 +44,10 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
describe("Anthropic Provider", () => {
|
||||
it.skipIf(!process.env.ANTHROPIC_API_KEY)(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "../src/api/cloudflare-ai-binding.ts";
|
||||
import { streamSimple as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const BINDING_PREFIX = "https://workers-binding.ai/ai-gateway/gateways/my-gateway";
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ describe("createAiBindingFetch", () => {
|
|||
|
||||
const result = await streamOpenAICompletions(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "hello", timestamp: 1 }] },
|
||||
normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }),
|
||||
{
|
||||
headers: {
|
||||
"cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
|
||||
import type { Api, Context, Model } from "../src/types.ts";
|
||||
import type { Api, Model } from "../src/types.ts";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const model: Model<Api> = {
|
||||
id: "model",
|
||||
|
|
@ -16,7 +17,7 @@ const model: Model<Api> = {
|
|||
maxTokens: 100,
|
||||
};
|
||||
|
||||
const context: Context = { messages: [] };
|
||||
const context = normalizeContext({ messages: [] });
|
||||
|
||||
describe("Cloudflare provider streams", () => {
|
||||
it("materializes the model endpoint before dispatch", () => {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import {
|
|||
resetOpenAICodexWebSocketDebugStats,
|
||||
stream as streamOpenAICodexResponses,
|
||||
} from "../src/api/openai-codex-responses.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Message, Model, Tool, ToolResultMessage, Transport } from "../src/types.ts";
|
||||
|
||||
type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
|
|
@ -172,12 +172,12 @@ async function main(): Promise<void> {
|
|||
if (!apiKey) {
|
||||
throw new Error("No OpenAI Codex API key found in coding-agent auth storage.");
|
||||
}
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt:
|
||||
"You are participating in a benchmark. For each benchmark turn, call deterministic_probe exactly once before the final answer. Keep final answers minimal.",
|
||||
messages: [],
|
||||
tools: [deterministicProbeTool()],
|
||||
};
|
||||
});
|
||||
const elapsed: number[] = [];
|
||||
resetOpenAICodexWebSocketDebugStats(args.sessionId);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
convertResponsesTools,
|
||||
processResponsesStream,
|
||||
} from "../src/api/openai-responses-shared.ts";
|
||||
import type { AssistantMessage, Context, Model, Tool, ToolCall } from "../src/types.ts";
|
||||
import type { AssistantMessage, Model, Tool, ToolCall } from "../src/types.ts";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
function makeModel(): Model<"openai-responses"> {
|
||||
return {
|
||||
|
|
@ -204,7 +205,7 @@ describe("constrained tool sampling", () => {
|
|||
name: "sample_tool",
|
||||
arguments: { payload: "abc" },
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
|
|
@ -225,7 +226,7 @@ describe("constrained tool sampling", () => {
|
|||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
for (const invalidArguments of [{}, { payload: 42 }]) {
|
||||
replayedToolCall.arguments = invalidArguments;
|
||||
expect(() =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildBaseOptions } from "../src/api/simple-options.ts";
|
||||
import type { AssistantMessage, Context, Model, Usage } from "../src/types.ts";
|
||||
import type { AssistantMessage, Model, Usage } from "../src/types.ts";
|
||||
import { estimateContextTokens } from "../src/utils/estimate.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
function createUsage(totalTokens: number): Usage {
|
||||
return {
|
||||
|
|
@ -42,14 +43,14 @@ const model: Model<"openai-responses"> = {
|
|||
|
||||
describe("context token estimation", () => {
|
||||
it("ignores stale assistant usage after a newer message is inserted before it", () => {
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{ role: "user", content: "summary", timestamp: 200 },
|
||||
createAssistant(100, 9_500),
|
||||
{ role: "user", content: "x".repeat(4_000), timestamp: 300 },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
expect(estimateContextTokens(context)).toEqual({
|
||||
tokens: 1_005,
|
||||
|
|
@ -61,7 +62,7 @@ describe("context token estimation", () => {
|
|||
});
|
||||
|
||||
it("uses assistant usage again after a response to the inserted context", () => {
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "summary", timestamp: 200 },
|
||||
createAssistant(100, 9_500),
|
||||
|
|
@ -69,7 +70,7 @@ describe("context token estimation", () => {
|
|||
createAssistant(400, 2_000),
|
||||
{ role: "user", content: "tail", timestamp: 500 },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
expect(estimateContextTokens(context)).toEqual({
|
||||
tokens: 2_001,
|
||||
|
|
|
|||
|
|
@ -1,550 +0,0 @@
|
|||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertMessages } from "../src/api/openai-completions.ts";
|
||||
import { getModel, streamSimple } from "../src/compat.ts";
|
||||
import type { Api, AssistantMessage, Context, Model, Tool, ToolResultMessage, UserMessage } from "../src/types.ts";
|
||||
import { estimateContextTokens } from "../src/utils/estimate.ts";
|
||||
|
||||
interface AnthropicToolPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
defer_loading?: boolean;
|
||||
}
|
||||
|
||||
interface AnthropicContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
tool_use_id?: string;
|
||||
content?: string | Array<{ type: string; tool_name?: string }>;
|
||||
source?: {
|
||||
type: string;
|
||||
media_type: string;
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnthropicPayload {
|
||||
tools?: AnthropicToolPayload[];
|
||||
messages: Array<{
|
||||
content: string | AnthropicContentBlock[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface OpenAIToolSearchCall {
|
||||
type: "tool_search_call";
|
||||
call_id?: string | null;
|
||||
execution?: string;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
interface OpenAIToolSearchOutput {
|
||||
type: "tool_search_output";
|
||||
call_id?: string | null;
|
||||
execution?: string;
|
||||
status?: string | null;
|
||||
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
|
||||
}
|
||||
|
||||
interface OpenAIAdditionalTools {
|
||||
type: "additional_tools";
|
||||
role: "developer";
|
||||
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
|
||||
}
|
||||
|
||||
interface OpenAIPayload {
|
||||
tools?: Array<{ name?: string; function?: { name: string } }>;
|
||||
input?: Array<
|
||||
OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string }
|
||||
>;
|
||||
}
|
||||
|
||||
interface KimiTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface KimiMessage {
|
||||
role: string;
|
||||
content?: unknown;
|
||||
tools?: KimiTool[];
|
||||
}
|
||||
|
||||
interface KimiPayload {
|
||||
tools?: KimiTool[];
|
||||
messages: KimiMessage[];
|
||||
}
|
||||
|
||||
class PayloadCaptured extends Error {}
|
||||
|
||||
function makeTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `The ${name} tool`,
|
||||
parameters: Type.Object({ value: Type.String() }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserMessage(timestamp: number): UserMessage {
|
||||
return { role: "user", content: "Hello", timestamp };
|
||||
}
|
||||
|
||||
function makeAssistantToolCall(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-6",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolResult(addedToolNames: string[]): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "base_tool",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
addedToolNames,
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(tools: Tool[], addedToolNames = ["late_tool"]): Context {
|
||||
return {
|
||||
messages: [makeUserMessage(1), makeAssistantToolCall(), makeToolResult(addedToolNames), makeUserMessage(4)],
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
function makeKimiModel(deferredToolsMode?: "kimi"): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "deferred-tools-model",
|
||||
name: "Deferred Tools Model",
|
||||
api: "openai-completions",
|
||||
provider: "moonshotai",
|
||||
baseUrl: "http://127.0.0.1:9/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
compat: deferredToolsMode ? { deferredToolsMode } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePayload<T>(model: Model<Api>, context: Context, apiKey = "fake-key"): Promise<T> {
|
||||
let captured: T | undefined;
|
||||
const stream = streamSimple({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
|
||||
apiKey,
|
||||
onPayload: (payload) => {
|
||||
captured = payload as T;
|
||||
throw new PayloadCaptured();
|
||||
},
|
||||
});
|
||||
await stream.result();
|
||||
if (!captured) throw new Error("Expected payload capture");
|
||||
return captured;
|
||||
}
|
||||
|
||||
function findAnthropicToolResultContent(payload: AnthropicPayload): AnthropicContentBlock[] {
|
||||
for (const message of payload.messages) {
|
||||
if (typeof message.content !== "string" && message.content.some((block) => block.type === "tool_result")) {
|
||||
return message.content;
|
||||
}
|
||||
}
|
||||
throw new Error("No tool result in payload");
|
||||
}
|
||||
|
||||
function findAnthropicToolResult(payload: AnthropicPayload): AnthropicContentBlock {
|
||||
const result = findAnthropicToolResultContent(payload).find((block) => block.type === "tool_result");
|
||||
if (!result) throw new Error("No tool result in payload");
|
||||
return result;
|
||||
}
|
||||
|
||||
function openAIToolNames(payload: OpenAIPayload): string[] {
|
||||
return (payload.tools ?? []).map((tool) => tool.name ?? tool.function?.name ?? "");
|
||||
}
|
||||
|
||||
function makeCodexToken(): string {
|
||||
return `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`;
|
||||
}
|
||||
|
||||
describe("deferred tools", () => {
|
||||
it("loads an Anthropic tool at its tool-result marker", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
||||
|
||||
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
|
||||
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
|
||||
});
|
||||
|
||||
it("preserves tool output as sibling content after emitting references", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const assistant = context.messages[1] as AssistantMessage;
|
||||
assistant.content = [
|
||||
{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} },
|
||||
{ type: "toolCall", id: "call_2", name: "base_tool", arguments: {} },
|
||||
];
|
||||
const firstResult = context.messages[2] as ToolResultMessage;
|
||||
firstResult.content = [
|
||||
{ type: "text", text: "work completed" },
|
||||
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
|
||||
];
|
||||
context.messages.splice(3, 0, {
|
||||
...makeToolResult([]),
|
||||
toolCallId: "call_2",
|
||||
content: [{ type: "text", text: "second result" }],
|
||||
});
|
||||
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
||||
|
||||
expect(findAnthropicToolResultContent(payload)).toMatchObject([
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: [{ type: "tool_reference", tool_name: "late_tool" }],
|
||||
},
|
||||
{ type: "tool_result", tool_use_id: "call_2", content: "second result" },
|
||||
{ type: "text", text: "work completed" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads a tool introduced by OpenAI history after switching to Anthropic", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const assistant = context.messages[1] as AssistantMessage;
|
||||
assistant.api = "openai-responses";
|
||||
assistant.provider = "openai";
|
||||
assistant.model = "gpt-5.4";
|
||||
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-8"), context);
|
||||
|
||||
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
|
||||
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
|
||||
});
|
||||
|
||||
it("does not resurrect a marked tool missing from Context.tools", async () => {
|
||||
const context = makeContext([makeTool("base_tool")]);
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
||||
|
||||
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool"]);
|
||||
const content = findAnthropicToolResult(payload).content;
|
||||
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a tool immediate when it was used before its marker", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const assistant = context.messages[1] as AssistantMessage;
|
||||
assistant.content = [{ type: "toolCall", id: "call_1", name: "late_tool", arguments: {} }];
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
||||
|
||||
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes OAuth names before checking prior tool usage", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["read"]);
|
||||
const assistant = context.messages[1] as AssistantMessage;
|
||||
assistant.content = [{ type: "toolCall", id: "call_1", name: "Read", arguments: {} }];
|
||||
const payload = await capturePayload<AnthropicPayload>(
|
||||
getModel("anthropic", "claude-opus-4-6"),
|
||||
context,
|
||||
"sk-ant-oat-fake",
|
||||
);
|
||||
|
||||
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "Read"]);
|
||||
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
||||
const content = findAnthropicToolResult(payload).content;
|
||||
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches OAuth-canonicalized markers to active tools", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["Read"]);
|
||||
const payload = await capturePayload<AnthropicPayload>(
|
||||
getModel("anthropic", "claude-opus-4-6"),
|
||||
context,
|
||||
"sk-ant-oat-fake",
|
||||
);
|
||||
|
||||
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "Read", defer_loading: true }]);
|
||||
const content = findAnthropicToolResult(payload).content;
|
||||
expect(
|
||||
Array.isArray(content) &&
|
||||
content.some((block) => block.type === "tool_reference" && block.tool_name === "Read"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates active tools after OAuth canonicalization", async () => {
|
||||
const context: Context = {
|
||||
messages: [makeUserMessage(1)],
|
||||
tools: [makeTool("read"), { ...makeTool("Read"), description: "Canonical definition" }],
|
||||
};
|
||||
const payload = await capturePayload<AnthropicPayload>(
|
||||
getModel("anthropic", "claude-opus-4-6"),
|
||||
context,
|
||||
"sk-ant-oat-fake",
|
||||
);
|
||||
|
||||
expect(payload.tools).toMatchObject([{ name: "Read", description: "Canonical definition" }]);
|
||||
});
|
||||
|
||||
it("uses the normal tool list when Anthropic tool references are unsupported", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const models: Model<"anthropic-messages">[] = [
|
||||
getModel("anthropic", "claude-haiku-4-5"),
|
||||
{ ...getModel("anthropic", "claude-opus-4-6"), id: "claude-sonnet-4-20250514" },
|
||||
];
|
||||
|
||||
for (const model of models) {
|
||||
const payload = await capturePayload<AnthropicPayload>(model, context);
|
||||
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps one immediate Anthropic tool when every current tool is marked", async () => {
|
||||
const context = makeContext([makeTool("late_tool")]);
|
||||
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
||||
|
||||
expect(payload.tools).toMatchObject([{ name: "late_tool" }]);
|
||||
expect(payload.tools?.[0]?.defer_loading).toBeUndefined();
|
||||
const content = findAnthropicToolResult(payload).content;
|
||||
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
||||
});
|
||||
|
||||
it("supports explicit Anthropic compatibility overrides", async () => {
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
...getModel("anthropic", "claude-opus-4-6"),
|
||||
provider: "anthropic-proxy",
|
||||
compat: { supportsToolReferences: true },
|
||||
};
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<AnthropicPayload>(model, context);
|
||||
|
||||
expect(payload.tools?.find((tool) => tool.name === "late_tool")?.defer_loading).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes Kimi deferred tools as system tool definitions", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<KimiPayload>(makeKimiModel("kimi"), context);
|
||||
|
||||
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool"]);
|
||||
const toolResultIndex = payload.messages.findIndex((message) => message.role === "tool");
|
||||
const systemToolIndex = payload.messages.findIndex((message) => message.tools !== undefined);
|
||||
expect(toolResultIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(systemToolIndex).toBeGreaterThan(toolResultIndex);
|
||||
expect(payload.messages[systemToolIndex]?.tools?.map((tool) => tool.function.name)).toEqual(["late_tool"]);
|
||||
});
|
||||
|
||||
it("emits Kimi deferred schemas after all tool results in a batch", () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool"), makeTool("later_tool")]);
|
||||
context.messages.splice(3, 0, {
|
||||
...makeToolResult(["later_tool"]),
|
||||
toolCallId: "call_2",
|
||||
});
|
||||
|
||||
const messages = convertMessages(makeKimiModel("kimi"), context, {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsUsageInStreaming: true,
|
||||
supportsFinishReason: true,
|
||||
maxTokensField: "max_tokens",
|
||||
requiresToolResultName: false,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
requiresReasoningContentOnAssistantMessages: false,
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
chatTemplateKwargs: {},
|
||||
chatTemplateArgs: {},
|
||||
zaiToolStream: false,
|
||||
supportsStrictMode: false,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
cacheControlFormat: undefined,
|
||||
sendSessionAffinityHeaders: false,
|
||||
deferredToolsMode: "kimi",
|
||||
sessionAffinityFormat: "openai",
|
||||
supportsLongCacheRetention: false,
|
||||
});
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "tool", "system", "user"]);
|
||||
expect((messages[4] as { tools?: KimiTool[] }).tools?.map((tool) => tool.function.name)).toEqual([
|
||||
"late_tool",
|
||||
"later_tool",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves OpenAI Completions tools unchanged without Kimi mode", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<KimiPayload>(makeKimiModel(), context);
|
||||
|
||||
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("loads an OpenAI Responses tool through additional_tools", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
|
||||
const additionalTools = payload.input?.find(
|
||||
(item): item is OpenAIAdditionalTools => item.type === "additional_tools",
|
||||
);
|
||||
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
||||
expect(additionalTools).toMatchObject({ role: "developer" });
|
||||
expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]);
|
||||
expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true);
|
||||
expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false);
|
||||
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an additional_tools marker after the loaded tool is used", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const lateCall: AssistantMessage = {
|
||||
...makeAssistantToolCall(),
|
||||
content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
};
|
||||
context.messages.splice(3, 0, lateCall, {
|
||||
...makeToolResult(["late_tool"]),
|
||||
toolCallId: "call_late|fc_late",
|
||||
toolName: "late_tool",
|
||||
});
|
||||
|
||||
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
|
||||
const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) =>
|
||||
item.type === "additional_tools" ? [index] : [],
|
||||
);
|
||||
const lateCallIndex = (payload.input ?? []).findIndex(
|
||||
(item) => item.type === "function_call" && item.name === "late_tool",
|
||||
);
|
||||
|
||||
expect(additionalToolIndexes).toHaveLength(1);
|
||||
expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex);
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
||||
});
|
||||
|
||||
it("falls back to client tool search when additional_tools is unsupported", async () => {
|
||||
const model: Model<"openai-responses"> = {
|
||||
...getModel("openai", "gpt-5.4"),
|
||||
provider: "openai-proxy",
|
||||
compat: { supportsAdditionalTools: false, supportsToolSearch: true },
|
||||
};
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<OpenAIPayload>(model, context);
|
||||
const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call");
|
||||
const searchOutput = payload.input?.find(
|
||||
(item): item is OpenAIToolSearchOutput => item.type === "tool_search_output",
|
||||
);
|
||||
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
||||
expect(searchCall).toMatchObject({ execution: "client", status: "completed" });
|
||||
expect(searchOutput?.call_id).toBe(searchCall?.call_id);
|
||||
expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]);
|
||||
expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)(
|
||||
"uses the normal tool list for unsupported OpenAI model %s",
|
||||
async (modelId) => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<OpenAIPayload>(getModel("openai", modelId), context);
|
||||
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the normal tool list when OpenAI tool search is explicitly disabled", async () => {
|
||||
const model: Model<"openai-responses"> = {
|
||||
...getModel("openai", "gpt-5.4"),
|
||||
provider: "openai-proxy",
|
||||
compat: { supportsToolSearch: false },
|
||||
};
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<OpenAIPayload>(model, context);
|
||||
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
||||
});
|
||||
|
||||
it("selects additional tools, tool search, or top-level tools for Codex models", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const additionalTools = await capturePayload<OpenAIPayload>(
|
||||
getModel("openai-codex", "gpt-5.6-sol"),
|
||||
context,
|
||||
makeCodexToken(),
|
||||
);
|
||||
const toolSearch = await capturePayload<OpenAIPayload>(
|
||||
getModel("openai-codex", "gpt-5.5"),
|
||||
context,
|
||||
makeCodexToken(),
|
||||
);
|
||||
const topLevel = await capturePayload<OpenAIPayload>(
|
||||
getModel("openai-codex", "gpt-5.3-codex-spark"),
|
||||
context,
|
||||
makeCodexToken(),
|
||||
);
|
||||
|
||||
expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]);
|
||||
expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true);
|
||||
expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
||||
expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]);
|
||||
expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true);
|
||||
expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]);
|
||||
expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false);
|
||||
expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves providers without deferred loading unchanged", async () => {
|
||||
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
||||
const payload = await capturePayload<OpenAIPayload>(getModel("groq", "llama-3.3-70b-versatile"), context);
|
||||
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
||||
});
|
||||
|
||||
it("counts definitions marked after the latest usage checkpoint", () => {
|
||||
const assistant: AssistantMessage = {
|
||||
...makeAssistantToolCall(),
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: {
|
||||
input: 50,
|
||||
output: 50,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
};
|
||||
const plain = estimateContextTokens({ messages: [assistant, makeUserMessage(4)], tools: [] });
|
||||
const lateTool = { ...makeTool("late_tool"), description: "x".repeat(4000) };
|
||||
const marked = estimateContextTokens({
|
||||
messages: [assistant, makeToolResult(["late_tool"])],
|
||||
tools: [lateTool],
|
||||
});
|
||||
|
||||
expect(marked.tokens).toBeGreaterThan(plain.tokens + 500);
|
||||
expect(marked.trailingTokens).toBeGreaterThan(plain.trailingTokens + 500);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,11 +9,12 @@ import { streamSimple as streamOpenAICompletions } from "../src/api/openai-compl
|
|||
import { streamSimple as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import { generateImages } from "../src/api/openrouter-images.ts";
|
||||
import { streamSimple as streamPiMessages } from "../src/api/pi-messages.ts";
|
||||
import type { Api, Context, FetchFunction, ImagesModel, Model } from "../src/types.ts";
|
||||
import type { Api, FetchFunction, ImagesModel, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
function createModel<TApi extends Api>(api: TApi): Model<TApi> {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
import type { MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/beta/messages/messages.js";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Model, Tool, ToolResultMessage } from "../src/types.ts";
|
||||
|
||||
const lookup: Tool = {
|
||||
name: "lookup",
|
||||
description: "Look up a synthetic key",
|
||||
parameters: Type.Object({ key: Type.String() }),
|
||||
};
|
||||
|
||||
function discoveryContext(model: Model<"anthropic-messages">, name = "tool_search"): Context {
|
||||
const assistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Find a lookup tool.", thinkingSignature: "" },
|
||||
{ type: "toolCall", id: "search1", name, arguments: { query: "lookup" } },
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: 0,
|
||||
};
|
||||
return {
|
||||
tools: [{ name, description: "Find tools", parameters: Type.Object({ query: Type.String() }) }, lookup],
|
||||
messages: [
|
||||
{ role: "user", content: "Look up alpha.", timestamp: 0 },
|
||||
assistant,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolName: name,
|
||||
toolCallId: "search1",
|
||||
content: [{ type: "text", text: "Found lookup." }],
|
||||
addedToolNames: ["lookup"],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function capture(model: Model<"anthropic-messages">, context: Context): Promise<MessageCreateParamsStreaming> {
|
||||
let payload: MessageCreateParamsStreaming | undefined;
|
||||
await streamAnthropic({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: "none",
|
||||
onPayload(value) {
|
||||
payload = value as MessageCreateParamsStreaming;
|
||||
throw new Error("payload captured");
|
||||
},
|
||||
}).result();
|
||||
if (!payload) throw new Error("Expected payload");
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Follow-up to #9323: Fireworks thinking support alone did not enable tool references.
|
||||
describe("Fireworks deferred tools", () => {
|
||||
it.each([
|
||||
"accounts/fireworks/models/deepseek-v4-flash-0731",
|
||||
"accounts/fireworks/models/qwen3p8-max",
|
||||
"accounts/fireworks/models/kimi-k2p6",
|
||||
] as const)("serializes discovery and replay for %s", async (id) => {
|
||||
const model = getModel("fireworks", id);
|
||||
for (const name of ["ToolSearch", "tool_search", "discover_tools"]) {
|
||||
const context = discoveryContext(model, name);
|
||||
const payload = await capture(model, context);
|
||||
expect(payload.tools).toEqual([
|
||||
{
|
||||
name,
|
||||
description: "Find tools",
|
||||
input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
||||
},
|
||||
{
|
||||
name: "lookup",
|
||||
description: lookup.description,
|
||||
input_schema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
||||
defer_loading: true,
|
||||
},
|
||||
]);
|
||||
expect(payload.messages[1].content).toContainEqual({
|
||||
type: "thinking",
|
||||
thinking: "Find a lookup tool.",
|
||||
signature: "",
|
||||
});
|
||||
expect(payload.messages[2].content).toEqual([
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "search1",
|
||||
content: [{ type: "tool_reference", tool_name: "lookup" }],
|
||||
is_error: false,
|
||||
},
|
||||
{ type: "text", text: "Found lookup." },
|
||||
]);
|
||||
|
||||
// Fireworks unwraps tool_use_tool on the wire, even when the ID retains that prefix.
|
||||
const events = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: { id: "msg_lookup", model: id, usage: { input_tokens: 100, output_tokens: 0 } },
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tool_use_tool_1", name: "lookup", input: {} },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"key":"alpha"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 10 } },
|
||||
{ type: "message_stop" },
|
||||
];
|
||||
const response = await streamAnthropic({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
|
||||
apiKey: "test-key",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
}).result();
|
||||
expect(response.stopReason).toBe("toolUse");
|
||||
expect(response.content).toEqual([
|
||||
{ type: "toolCall", id: "tool_use_tool_1", name: "lookup", arguments: { key: "alpha" } },
|
||||
]);
|
||||
context.messages.push(response, {
|
||||
role: "toolResult",
|
||||
toolCallId: "tool_use_tool_1",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "text", text: "SYNTHETIC_ALPHA_17" }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
});
|
||||
const replay = await capture(model, context);
|
||||
expect(replay.tools).toEqual(payload.tools);
|
||||
expect(replay.messages[2]).toEqual(payload.messages[2]);
|
||||
expect(replay.messages[3].content).toEqual([
|
||||
{ type: "tool_use", id: "tool_use_tool_1", name: "lookup", input: { key: "alpha" } },
|
||||
]);
|
||||
expect(replay.messages[4].content).toEqual([
|
||||
{ type: "tool_result", tool_use_id: "tool_use_tool_1", content: "SYNTHETIC_ALPHA_17", is_error: false },
|
||||
]);
|
||||
context.messages.push({ role: "user", content: "Now look up beta.", timestamp: 0 });
|
||||
const next = await capture(model, context);
|
||||
expect(next.tools).toEqual(payload.tools);
|
||||
expect(next.messages.slice(0, -1)).toEqual(replay.messages);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates references across multiple results and preserves ordinary text", async () => {
|
||||
const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
|
||||
const context = discoveryContext(model);
|
||||
const assistant = context.messages[1] as AssistantMessage;
|
||||
assistant.content.push({ type: "toolCall", id: "search2", name: "tool_search", arguments: { query: "lookup" } });
|
||||
const result = context.messages[2] as ToolResultMessage;
|
||||
result.addedToolNames = ["lookup", "lookup", "missing"];
|
||||
context.messages.push({ ...result, toolCallId: "search2", content: [{ type: "text", text: "Already loaded." }] });
|
||||
const payload = await capture(model, context);
|
||||
expect(payload.messages[2].content).toEqual([
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "search1",
|
||||
content: [{ type: "tool_reference", tool_name: "lookup" }],
|
||||
is_error: false,
|
||||
},
|
||||
{ type: "tool_result", tool_use_id: "search2", content: "Already loaded.", is_error: false },
|
||||
{ type: "text", text: "Found lookup." },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["no-discovery", "no-markers", "no-immediate", "already-used", "disabled"])(
|
||||
"keeps normal schemas for %s",
|
||||
async (scenario) => {
|
||||
const base = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
|
||||
const model = { ...base, compat: { ...base.compat, supportsToolReferences: scenario !== "disabled" } };
|
||||
const context = discoveryContext(model);
|
||||
if (scenario === "no-discovery") context.messages = context.messages.slice(0, 1);
|
||||
if (scenario === "no-markers") delete (context.messages[2] as ToolResultMessage).addedToolNames;
|
||||
if (scenario === "no-immediate") context.tools = [lookup];
|
||||
if (scenario === "already-used") {
|
||||
const assistant = structuredClone(context.messages[1]) as AssistantMessage;
|
||||
assistant.content = [{ type: "toolCall", id: "earlier", name: "lookup", arguments: { key: "before" } }];
|
||||
context.messages.splice(1, 0, assistant, {
|
||||
role: "toolResult",
|
||||
toolCallId: "earlier",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "text", text: "BEFORE" }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
});
|
||||
}
|
||||
const payload = await capture(model, context);
|
||||
expect(payload.tools?.some((tool) => "defer_loading" in tool)).toBe(false);
|
||||
expect(JSON.stringify(payload.messages)).not.toContain("tool_reference");
|
||||
expect(payload.tools).toContainEqual(expect.objectContaining({ name: "lookup" }));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import type { ModelsDevReasoningOption } from "../scripts/models-dev-reasoning-o
|
|||
import { streamSimple } from "../src/api/anthropic-messages.ts";
|
||||
import { getSupportedThinkingLevels, hasApi } from "../src/models.ts";
|
||||
import type { Api, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const temporaryRoots: string[] = [];
|
||||
|
|
@ -111,18 +112,14 @@ describe("Fireworks model generation", () => {
|
|||
expect(model.compat?.forceAdaptiveThinking).toBe(true);
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(["off", "low", "max"]);
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
await streamSimple(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
|
||||
{
|
||||
apiKey: "test-fireworks-key",
|
||||
reasoning: "max",
|
||||
onPayload: (value) => {
|
||||
payload = value as Record<string, unknown>;
|
||||
throw new Error("payload captured");
|
||||
},
|
||||
await streamSimple(model, normalizeContext({ messages: [{ role: "user", content: "test", timestamp: 0 }] }), {
|
||||
apiKey: "test-fireworks-key",
|
||||
reasoning: "max",
|
||||
onPayload: (value) => {
|
||||
payload = value as Record<string, unknown>;
|
||||
throw new Error("payload captured");
|
||||
},
|
||||
).result();
|
||||
}).result();
|
||||
expect(payload?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
||||
expect(payload?.output_config).toEqual({ effort: "max" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net";
|
|||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel, getModels, streamSimple } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext, streamSimple } from "../src/compat.ts";
|
||||
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
|
||||
import { getSupportedThinkingLevels } from "../src/models.ts";
|
||||
import type { Context, Model, Tool } from "../src/types.ts";
|
||||
|
|
@ -19,16 +19,6 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("Fireworks models", () => {
|
||||
it("enables native tool references only on Messages models", () => {
|
||||
for (const model of getModels("fireworks")) {
|
||||
if (model.api === "anthropic-messages") {
|
||||
expect(model.compat).toMatchObject({ supportsToolReferences: true });
|
||||
} else {
|
||||
expect(model.compat).not.toHaveProperty("supportsToolReferences");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the default Kimi K2.6 model via Anthropic-compatible Messages API", () => {
|
||||
const model = getModel("fireworks", "accounts/fireworks/models/kimi-k2p6");
|
||||
|
||||
|
|
@ -91,7 +81,8 @@ describe("Fireworks models", () => {
|
|||
supportsDeveloperRole: false,
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
thinkingFormat: "openai",
|
||||
deferredToolsMode: "kimi",
|
||||
supportsMidConvoSystemMessages: true,
|
||||
supportsMidConvoToolAdditions: true,
|
||||
sendSessionAffinityHeaders: true,
|
||||
supportsLongCacheRetention: false,
|
||||
};
|
||||
|
|
@ -213,7 +204,6 @@ describe("Fireworks models", () => {
|
|||
expect(model.compat?.supportsCacheControlOnTools).toBe(false);
|
||||
expect(model.compat?.supportsLongCacheRetention).toBe(false);
|
||||
expect(model.compat?.allowEmptySignature).toBe(true);
|
||||
expect(model.compat?.supportsToolReferences).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -231,7 +221,6 @@ const tool: Tool = {
|
|||
};
|
||||
|
||||
const FIREWORKS_ANTHROPIC_COMPAT = {
|
||||
supportsToolReferences: true,
|
||||
allowEmptySignature: true,
|
||||
sendSessionAffinityHeaders: true,
|
||||
supportsEagerToolInputStreaming: false,
|
||||
|
|
@ -323,7 +312,7 @@ async function captureAnthropicRequest(
|
|||
// Override the model's baseUrl to point to the local test server
|
||||
const localModel = { ...model, baseUrl: `http://127.0.0.1:${address.port}` };
|
||||
|
||||
const stream = streamAnthropic(localModel, context, {
|
||||
const stream = streamAnthropic(localModel, normalizeContext(context), {
|
||||
apiKey: "test-key",
|
||||
cacheRetention: (options?.cacheRetention as "none" | "short" | "long") ?? "short",
|
||||
sessionId: options?.sessionId,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import { getSupportedThinkingLevels } from "../src/models.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
constructorOpts: undefined as Record<string, unknown> | undefined,
|
||||
|
|
@ -52,10 +51,10 @@ vi.mock("@anthropic-ai/sdk", () => {
|
|||
});
|
||||
|
||||
describe("Copilot Claude via Anthropic Messages", () => {
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
it("applies Copilot-specific adaptive thinking effort overrides", () => {
|
||||
const opus47 = getModel("github-copilot", "claude-opus-4.7");
|
||||
|
|
|
|||
|
|
@ -87,14 +87,13 @@ vi.mock("@google/genai", () => {
|
|||
|
||||
import { stream as streamGoogleGenerativeAi } from "../src/api/google-generative-ai.ts";
|
||||
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
async function captureGoogleHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
|
||||
googleGenAiMock.constructorCalls.length = 0;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertMessages, requiresToolCallId } from "../src/api/google-shared.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
function makeGemini3Model<TApi extends "google-generative-ai" | "google-vertex">(
|
||||
api: TApi,
|
||||
|
|
@ -84,7 +85,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||
makeGemini3Model("google-vertex", "google-vertex"),
|
||||
])("preserves tool call IDs for $id via $api history", (model) => {
|
||||
const context = makeContext(model);
|
||||
const contents = convertMessages(model, context);
|
||||
const contents = convertMessages(model, normalizeContext(context));
|
||||
const functionCallIds = contents
|
||||
.flatMap((content) => content.parts ?? [])
|
||||
.flatMap((part) => (part.functionCall?.id ? [part.functionCall.id] : []));
|
||||
|
|
@ -98,7 +99,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||
|
||||
it("does not add skip_thought_signature_validator for unsigned Google Gen AI tool calls", () => {
|
||||
const model = makeGemini3Model("google-generative-ai", "google");
|
||||
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext({ ...model, id: "other-model" })));
|
||||
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
expect(modelTurn).toBeTruthy();
|
||||
|
|
@ -116,7 +117,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||
|
||||
it("does not add skip_thought_signature_validator for unsigned Vertex tool calls", () => {
|
||||
const model = makeGemini3Model("google-vertex", "google-vertex");
|
||||
const contents = convertMessages(model, makeContext(model));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext(model)));
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||
it("preserves valid thoughtSignature when present for the same provider and model", () => {
|
||||
const model = makeGemini3Model("google-generative-ai", "google");
|
||||
const validSig = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
const contents = convertMessages(model, makeContext(model, validSig));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext(model, validSig)));
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const functionCallParts = modelTurn?.parts?.filter((p) => p.functionCall !== undefined) ?? [];
|
||||
|
||||
|
|
@ -140,7 +141,7 @@ describe("google-shared convertMessages — Gemini 3 unsigned tool calls", () =>
|
|||
|
||||
it("does not add a thoughtSignature for non-Gemini-3 models", () => {
|
||||
const model = makeGemini3Model("google-generative-ai", "google", "gemini-2.5-flash");
|
||||
const contents = convertMessages(model, makeContext({ ...model, id: "other-model" }));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext({ ...model, id: "other-model" })));
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const functionCallParts = modelTurn?.parts?.filter((part) => part.functionCall !== undefined) ?? [];
|
||||
const functionResponseParts = contents
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertMessages } from "../src/api/google-shared.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
function makeModel<TApi extends "google-generative-ai">(
|
||||
api: TApi,
|
||||
|
|
@ -78,7 +79,7 @@ function makeContext(model: { api: string; provider: string; id: string }): Cont
|
|||
describe("google-shared image tool result routing", () => {
|
||||
it("keeps separate synthetic image turn for Gemini 2.x Google API models", () => {
|
||||
const model = makeModel("google-generative-ai", "google", "gemini-2.5-flash");
|
||||
const contents = convertMessages(model, makeContext(model));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext(model)));
|
||||
|
||||
expect(contents).toHaveLength(5);
|
||||
expect(contents[2].parts?.every((part) => part.functionResponse)).toBe(true);
|
||||
|
|
@ -89,7 +90,7 @@ describe("google-shared image tool result routing", () => {
|
|||
|
||||
it("nests image tool results for Gemini 3 Google API models", () => {
|
||||
const model = makeModel("google-generative-ai", "google", "gemini-3-pro-preview");
|
||||
const contents = convertMessages(model, makeContext(model));
|
||||
const contents = convertMessages(model, normalizeContext(makeContext(model)));
|
||||
|
||||
expect(contents).toHaveLength(3);
|
||||
const toolResultTurn = contents[2];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertMessages } from "../src/api/google-shared.ts";
|
||||
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
// Gemini can attach `thoughtSignature` to a response part whose visible text is empty (e.g. a
|
||||
// thought burst preceding a function call) and requires the signature echoed back on the next
|
||||
|
|
@ -59,10 +60,12 @@ describe("google-shared convertMessages — signed empty blocks", () => {
|
|||
const model = makeModel();
|
||||
const contents = convertMessages(
|
||||
model,
|
||||
makeContext(model, [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
normalizeContext(
|
||||
makeContext(model, [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const signed = modelTurn?.parts?.filter((p) => p.thoughtSignature === VALID_SIG) ?? [];
|
||||
|
|
@ -74,10 +77,12 @@ describe("google-shared convertMessages — signed empty blocks", () => {
|
|||
const model = makeModel();
|
||||
const contents = convertMessages(
|
||||
model,
|
||||
makeContext(model, [
|
||||
{ type: "text", text: "", textSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
normalizeContext(
|
||||
makeContext(model, [
|
||||
{ type: "text", text: "", textSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
const signed = modelTurn?.parts?.filter((p) => p.thoughtSignature === VALID_SIG) ?? [];
|
||||
|
|
@ -88,11 +93,13 @@ describe("google-shared convertMessages — signed empty blocks", () => {
|
|||
const model = makeModel();
|
||||
const contents = convertMessages(
|
||||
model,
|
||||
makeContext(model, [
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
normalizeContext(
|
||||
makeContext(model, [
|
||||
{ type: "thinking", thinking: "" },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
expect(modelTurn?.parts).toHaveLength(1);
|
||||
|
|
@ -103,11 +110,13 @@ describe("google-shared convertMessages — signed empty blocks", () => {
|
|||
const model = makeModel();
|
||||
const contents = convertMessages(
|
||||
model,
|
||||
makeContext({ ...model, id: "other-model" }, [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
|
||||
{ type: "text", text: "", textSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
normalizeContext(
|
||||
makeContext({ ...model, id: "other-model" }, [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: VALID_SIG },
|
||||
{ type: "text", text: "", textSignature: VALID_SIG },
|
||||
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const modelTurn = contents.find((c) => c.role === "model");
|
||||
expect(modelTurn?.parts).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,12 @@ import { describe, expect, it } from "vitest";
|
|||
import { streamSimple as streamSimpleGoogle } from "../src/api/google-generative-ai.ts";
|
||||
import { resolveGoogleThinkingLevel } from "../src/api/google-shared.ts";
|
||||
import { streamSimple as streamSimpleVertex } from "../src/api/google-vertex.ts";
|
||||
import type {
|
||||
Context,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
ThinkingBudgets,
|
||||
ThinkingLevel,
|
||||
ThinkingLevelMap,
|
||||
} from "../src/types.ts";
|
||||
import type { Model, ModelThinkingLevel, ThinkingBudgets, ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
|
||||
};
|
||||
});
|
||||
|
||||
function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -47,14 +47,14 @@ vi.mock("@google/genai", () => {
|
|||
});
|
||||
|
||||
import { stream as streamGoogleVertex } from "../src/api/google-vertex.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
const model = getModel("google-vertex", "gemini-3-flash-preview");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const originalGoogleCloudApiKey = process.env.GOOGLE_CLOUD_API_KEY;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { streamSimple as streamSimpleOpenAICodexResponses } from "../src/api/openai-codex-responses.ts";
|
||||
import { clampThinkingLevel, getModel, getSupportedThinkingLevels } from "../src/compat.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { clampThinkingLevel, getModel, getSupportedThinkingLevels, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
function mockToken(): string {
|
||||
const payload = Buffer.from(
|
||||
|
|
@ -69,10 +69,10 @@ describe("max thinking level", () => {
|
|||
|
||||
it.each(["gpt-5.6-sol", "gpt-6-astra"] as const)("sends max to the Codex Responses API for %s", async (modelId) => {
|
||||
const model = getModel("openai-codex", modelId)!;
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
let payload: unknown;
|
||||
|
||||
await streamSimpleOpenAICodexResponses(model, context, {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { arch, platform, release } from "node:os";
|
|||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { FetchFunction, ProviderResponse } from "../src/types.ts";
|
||||
|
||||
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ function createTerminalEvent(finishReason = "stop") {
|
|||
describe("Mistral HTTP transport", () => {
|
||||
it("serializes SDK-style payloads to the Mistral wire format", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "Be precise",
|
||||
messages: [
|
||||
{
|
||||
|
|
@ -58,7 +58,7 @@ describe("Mistral HTTP transport", () => {
|
|||
parameters: Type.Object({ query: Type.String() }),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
let requestUrl: string | undefined;
|
||||
let requestInit: RequestInit | undefined;
|
||||
let callbackPayload: Record<string, unknown> | undefined;
|
||||
|
|
@ -160,7 +160,7 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("serializes assistant thinking, tool calls, and tool results for replay", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
|
|
@ -195,7 +195,7 @@ describe("Mistral HTTP transport", () => {
|
|||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
let requestInit: RequestInit | undefined;
|
||||
const fetch: FetchFunction = async (_input, init) => {
|
||||
requestInit = init;
|
||||
|
|
@ -237,9 +237,9 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("parses native thinking, text, fragmented tool calls, and cached-token usage", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const events = [
|
||||
{
|
||||
id: "response-1",
|
||||
|
|
@ -324,9 +324,9 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("parses SSE and UTF-8 sequences split across transport chunks", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const fetch: FetchFunction = async () =>
|
||||
createBytewiseSseResponse({
|
||||
id: "response-bytewise",
|
||||
|
|
@ -346,9 +346,9 @@ describe("Mistral HTTP transport", () => {
|
|||
...getModel("mistral", "mistral-large-latest"),
|
||||
headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" },
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
let requestHeaders: Headers | undefined;
|
||||
const fetch: FetchFunction = async (_input, init) => {
|
||||
requestHeaders = new Headers(init?.headers);
|
||||
|
|
@ -369,9 +369,9 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("aborts while waiting for an SSE chunk", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const fetch: FetchFunction = async () =>
|
||||
new Response(
|
||||
|
|
@ -394,9 +394,9 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("applies the request timeout while waiting for an SSE chunk", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const fetch: FetchFunction = async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
|
|
@ -417,9 +417,9 @@ describe("Mistral HTTP transport", () => {
|
|||
|
||||
it("preserves HTTP status and response bodies in errors", async () => {
|
||||
const model = getModel("mistral", "mistral-large-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const fetch: FetchFunction = async () =>
|
||||
new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Context, FetchFunction } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { FetchFunction } from "../src/types.ts";
|
||||
|
||||
const model = getModel("mistral", "devstral-medium-latest");
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
function createFetch(finishReason: string): FetchFunction {
|
||||
return async () =>
|
||||
|
|
@ -33,7 +33,10 @@ function createFetch(finishReason: string): FetchFunction {
|
|||
|
||||
describe("Mistral raw stop reasons", () => {
|
||||
it("preserves raw Mistral finish reasons for successful stops", async () => {
|
||||
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result();
|
||||
const message = await streamMistral(model, context, {
|
||||
apiKey: "test",
|
||||
fetch: createFetch("stop"),
|
||||
}).result();
|
||||
|
||||
expect(message.stopReason).toBe("stop");
|
||||
expect(message.rawStopReason).toBe("stop");
|
||||
|
|
@ -41,7 +44,10 @@ describe("Mistral raw stop reasons", () => {
|
|||
});
|
||||
|
||||
it("preserves raw Mistral finish reasons for provider error stops", async () => {
|
||||
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result();
|
||||
const message = await streamMistral(model, context, {
|
||||
apiKey: "test",
|
||||
fetch: createFetch("error"),
|
||||
}).result();
|
||||
|
||||
expect(message.stopReason).toBe("error");
|
||||
expect(message.rawStopReason).toBe("error");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { streamSimple } from "../src/api/mistral-conversations.ts";
|
||||
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
interface MistralPayload {
|
||||
promptMode?: "reasoning";
|
||||
|
|
@ -34,7 +35,7 @@ async function capturePayload(
|
|||
options?: SimpleStreamOptions,
|
||||
): Promise<MistralPayload> {
|
||||
let capturedPayload: MistralPayload | undefined;
|
||||
const stream = streamSimple(model, makeContext(), {
|
||||
const stream = streamSimple(model, normalizeContext(makeContext()), {
|
||||
...options,
|
||||
apiKey: "fake-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
streamSimple as streamSimpleOpenAICodexResponses,
|
||||
} from "../src/api/openai-codex-responses.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
|
|
@ -186,12 +187,15 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" });
|
||||
const streamResult = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
});
|
||||
let sawTextDelta = false;
|
||||
let sawDone = false;
|
||||
|
||||
|
|
@ -225,10 +229,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const resultStream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
|
|
@ -284,10 +288,10 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
|
||||
|
|
@ -345,10 +349,10 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" }).result(),
|
||||
|
|
@ -401,10 +405,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -498,10 +502,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const events: string[] = [];
|
||||
|
||||
|
|
@ -617,12 +621,16 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId, transport: "sse" });
|
||||
const streamResult = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
sessionId,
|
||||
transport: "sse",
|
||||
});
|
||||
await streamResult.result();
|
||||
});
|
||||
|
||||
|
|
@ -660,10 +668,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -710,10 +718,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -760,10 +768,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -822,10 +830,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
await streamSimpleOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -873,7 +881,7 @@ describe("openai-codex streaming", () => {
|
|||
|
||||
await streamOpenAICodexResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Do not call ping. Respond with text instead.", timestamp: Date.now() },
|
||||
],
|
||||
|
|
@ -884,7 +892,7 @@ describe("openai-codex streaming", () => {
|
|||
parameters: Type.Object({ value: Type.String() }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ apiKey: token, transport: "sse", toolChoice: "required" },
|
||||
).result();
|
||||
|
||||
|
|
@ -928,7 +936,7 @@ describe("openai-codex streaming", () => {
|
|||
|
||||
await streamOpenAICodexResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [{ role: "user", content: "Use a tool", timestamp: Date.now() }],
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -944,7 +952,7 @@ describe("openai-codex streaming", () => {
|
|||
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
|
|
@ -1046,10 +1054,10 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const streamResult = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1142,10 +1150,10 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1246,13 +1254,16 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
// No sessionId provided
|
||||
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, transport: "sse" });
|
||||
const streamResult = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
});
|
||||
await streamResult.result();
|
||||
});
|
||||
it("forwards auto transport from streamSimple options and uses cached websocket context", async () => {
|
||||
|
|
@ -1349,10 +1360,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await streamSimpleOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1444,12 +1455,21 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = { systemPrompt: "", messages: [] };
|
||||
const context = normalizeContext({ systemPrompt: "", messages: [] });
|
||||
const options = { sessionId: "shared-session", transport: "websocket-cached" as const };
|
||||
|
||||
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-a") }).result();
|
||||
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-b") }).result();
|
||||
await streamOpenAICodexResponses(model, context, { ...options, apiKey: mockToken("account-a") }).result();
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
...options,
|
||||
apiKey: mockToken("account-a"),
|
||||
}).result();
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
...options,
|
||||
apiKey: mockToken("account-b"),
|
||||
}).result();
|
||||
await streamOpenAICodexResponses(model, context, {
|
||||
...options,
|
||||
apiKey: mockToken("account-a"),
|
||||
}).result();
|
||||
|
||||
expect(connectedHeaders.map((headers) => headers["chatgpt-account-id"])).toEqual(["account-a", "account-b"]);
|
||||
expect(connectedHeaders.map((headers) => headers.authorization)).toEqual([
|
||||
|
|
@ -1535,10 +1555,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
const options = {
|
||||
apiKey: token,
|
||||
cacheRetention: "none" as const,
|
||||
|
|
@ -1618,10 +1638,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const resultPromise = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1693,13 +1713,9 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const result = await streamOpenAICodexResponses(
|
||||
model,
|
||||
{ systemPrompt: "", messages: [] },
|
||||
{
|
||||
apiKey: token,
|
||||
},
|
||||
).result();
|
||||
const result = await streamOpenAICodexResponses(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
||||
apiKey: token,
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(connections).toBe(2);
|
||||
|
|
@ -1782,10 +1798,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const resultPromise = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1873,10 +1889,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const resultPromise = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -1968,10 +1984,10 @@ describe("openai-codex streaming", () => {
|
|||
maxTokens: 128000,
|
||||
};
|
||||
const sessionId = "aged-ws-session";
|
||||
const firstContext: Context = {
|
||||
const firstContext = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const first = await streamOpenAICodexResponses(model, firstContext, {
|
||||
apiKey: token,
|
||||
|
|
@ -1979,10 +1995,9 @@ describe("openai-codex streaming", () => {
|
|||
transport: "websocket-cached",
|
||||
}).result();
|
||||
vi.setSystemTime(new Date(startedAt.getTime() + 56 * 60 * 1000));
|
||||
const secondContext: Context = {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
const secondContext = normalizeContext({
|
||||
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
|
||||
};
|
||||
});
|
||||
|
||||
await streamOpenAICodexResponses(model, secondContext, {
|
||||
apiKey: token,
|
||||
|
|
@ -2117,13 +2132,13 @@ describe("openai-codex streaming", () => {
|
|||
],
|
||||
};
|
||||
|
||||
const first = await streamOpenAICodexResponses(model, firstContext, {
|
||||
const first = await streamOpenAICodexResponses(model, normalizeContext(firstContext), {
|
||||
apiKey: token,
|
||||
sessionId: "session-1",
|
||||
transport: "websocket-cached",
|
||||
}).result();
|
||||
|
||||
const secondContext: Context = {
|
||||
const secondContext = normalizeContext({
|
||||
...firstContext,
|
||||
messages: [
|
||||
...firstContext.messages,
|
||||
|
|
@ -2138,7 +2153,7 @@ describe("openai-codex streaming", () => {
|
|||
},
|
||||
{ role: "user", content: "Now finish", timestamp: 3 },
|
||||
],
|
||||
};
|
||||
});
|
||||
await streamOpenAICodexResponses(model, secondContext, {
|
||||
apiKey: token,
|
||||
sessionId: "session-1",
|
||||
|
|
@ -2333,20 +2348,19 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const firstContext: Context = {
|
||||
const firstContext = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: 1 }],
|
||||
};
|
||||
});
|
||||
|
||||
const first = await streamOpenAICodexResponses(model, firstContext, {
|
||||
apiKey: token,
|
||||
sessionId,
|
||||
transport: "websocket-cached",
|
||||
}).result();
|
||||
const secondContext: Context = {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
const secondContext = normalizeContext({
|
||||
messages: [...firstContext.messages, first, { role: "user", content: "Now finish", timestamp: 2 }],
|
||||
};
|
||||
});
|
||||
const eventTypes: string[] = [];
|
||||
const secondStream = streamOpenAICodexResponses(model, secondContext, {
|
||||
apiKey: token,
|
||||
|
|
@ -2443,10 +2457,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const resultPromise = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -2485,10 +2499,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
|
|
@ -2546,10 +2560,10 @@ describe("openai-codex streaming", () => {
|
|||
const largeText = "compress me ".repeat(400);
|
||||
await streamOpenAICodexResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: largeText, timestamp: 1 }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: token, transport: "sse" },
|
||||
).result();
|
||||
|
||||
|
|
@ -2564,10 +2578,10 @@ describe("openai-codex streaming", () => {
|
|||
capturedBody = undefined;
|
||||
await streamOpenAICodexResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "hi", timestamp: 1 }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: token, transport: "sse" },
|
||||
).result();
|
||||
|
||||
|
|
@ -2622,10 +2636,10 @@ describe("openai-codex streaming", () => {
|
|||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
const retryTimeoutDelays = () =>
|
||||
setTimeoutSpy.mock.calls
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Type } from "typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Message, Model } from "../src/types.ts";
|
||||
|
||||
interface CacheControl {
|
||||
|
|
@ -80,7 +80,7 @@ async function capturePayload(
|
|||
|
||||
await streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "System prompt",
|
||||
messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
|
||||
tools: [
|
||||
|
|
@ -92,7 +92,7 @@ async function capturePayload(
|
|||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-key", ...options },
|
||||
).result();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
interface FakeOpenAIClientOptions {
|
||||
|
|
@ -99,10 +99,10 @@ describe("openai-completions prompt caching", () => {
|
|||
) {
|
||||
await streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-key", ...options },
|
||||
).result();
|
||||
|
||||
|
|
@ -181,6 +181,14 @@ describe("openai-completions prompt caching", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("sends Baseten session affinity for built-in catalog models", async () => {
|
||||
const model = getModel("baseten", "zai-org/GLM-5.2");
|
||||
const { headers } = await captureRequest({ sessionId: "baseten-catalog-session" }, model);
|
||||
|
||||
expect(headers["x-session-affinity"]).toBe("baseten-catalog-session");
|
||||
expect(headers["x-client-request-id"]).toBe("baseten-catalog-session");
|
||||
});
|
||||
|
||||
it("uses OpenAI no-session format when configured", async () => {
|
||||
const model = createModel({
|
||||
compat: { sendSessionAffinityHeaders: true, sessionAffinityFormat: "openai-nosession" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
chunks: [] as unknown[],
|
||||
|
|
@ -48,9 +49,9 @@ const model: Model<"openai-completions"> = {
|
|||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
});
|
||||
|
||||
describe("OpenAI completions raw stop reasons", () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { AssistantMessage, Model, Tool } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
chunkSets: [] as unknown[][],
|
||||
|
|
@ -96,7 +97,9 @@ function toolCallChunk(): unknown {
|
|||
}
|
||||
|
||||
async function runOpenAICompletionsStream(messages: AssistantMessage[] = []): Promise<AssistantMessage> {
|
||||
return await streamOpenAICompletions(model(), { messages, tools: [readTool] }, { apiKey: "test" }).result();
|
||||
return await streamOpenAICompletions(model(), normalizeContext({ messages, tools: [readTool] }), {
|
||||
apiKey: "test",
|
||||
}).result();
|
||||
}
|
||||
|
||||
function getAssistantPayload(payload: unknown): { reasoning?: unknown; reasoning_details?: unknown } | undefined {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
requestOptions: [] as unknown[],
|
||||
|
|
@ -60,11 +61,11 @@ const model: Model<"openai-completions"> = {
|
|||
maxTokens: 100,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
|
||||
tools: [],
|
||||
};
|
||||
});
|
||||
|
||||
async function consume(options?: { maxRetries?: number; maxRetryDelayMs?: number }) {
|
||||
const stream = streamOpenAICompletions(model, context, { apiKey: "test", ...options });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
OpenAICompletionsCompat,
|
||||
Usage,
|
||||
} from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const emptyUsage: Usage = {
|
||||
input: 0,
|
||||
|
|
@ -42,16 +43,17 @@ const compat = {
|
|||
thinkingTokenBudgetField: undefined,
|
||||
supportsStrictMode: true,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
supportsMidConvoSystemMessages: false,
|
||||
supportsMidConvoToolAdditions: false,
|
||||
cacheControlFormat: undefined,
|
||||
sendSessionAffinityHeaders: false,
|
||||
sessionAffinityFormat: "openai",
|
||||
supportsLongCacheRetention: true,
|
||||
} satisfies Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
"cacheControlFormat" | "deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
|
||||
"cacheControlFormat" | "thinkingTokenBudgetField" | "vllmPriority"
|
||||
> & {
|
||||
cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"];
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
|
||||
};
|
||||
|
||||
|
|
@ -117,11 +119,13 @@ describe("openai-completions thinking-as-text replay", () => {
|
|||
it("serializes same-model thinking-plus-text replay as assistant text parts", () => {
|
||||
const messages = convertMessages(
|
||||
buildModel(),
|
||||
buildContext(
|
||||
buildAssistant([
|
||||
{ type: "thinking", thinking: "internal reasoning" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
]),
|
||||
normalizeContext(
|
||||
buildContext(
|
||||
buildAssistant([
|
||||
{ type: "thinking", thinking: "internal reasoning" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
]),
|
||||
),
|
||||
),
|
||||
compat,
|
||||
);
|
||||
|
|
@ -138,7 +142,7 @@ describe("openai-completions thinking-as-text replay", () => {
|
|||
it("serializes same-model thinking-only replay as assistant text parts", () => {
|
||||
const messages = convertMessages(
|
||||
buildModel(),
|
||||
buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }])),
|
||||
normalizeContext(buildContext(buildAssistant([{ type: "thinking", thinking: "internal reasoning" }]))),
|
||||
compat,
|
||||
);
|
||||
|
||||
|
|
@ -198,11 +202,13 @@ describe("openai-completions thinking-as-text replay", () => {
|
|||
const events = await collectEvents(
|
||||
streamOpenAICompletions(
|
||||
buildModel(`http://127.0.0.1:${port}`),
|
||||
buildContext(
|
||||
buildAssistant([
|
||||
{ type: "thinking", thinking: "internal reasoning" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
]),
|
||||
normalizeContext(
|
||||
buildContext(
|
||||
buildAssistant([
|
||||
{ type: "thinking", thinking: "internal reasoning" },
|
||||
{ type: "text", text: "visible answer" },
|
||||
]),
|
||||
),
|
||||
),
|
||||
{ apiKey: "test-key" },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Type } from "typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { convertMessages } from "../src/api/openai-completions.ts";
|
||||
import { getModel, stream, streamSimple } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext, stream, streamSimple } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Model, SimpleStreamOptions, Tool, ToolResultMessage } from "../src/types.ts";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
|
|
@ -1295,7 +1295,7 @@ describe("openai-completions tool_choice", () => {
|
|||
const model = { ...baseModel, api: "openai-completions" } as Model<"openai-completions">;
|
||||
const messages = convertMessages(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
|
|
@ -1318,7 +1318,7 @@ describe("openai-completions tool_choice", () => {
|
|||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
...model.compat,
|
||||
supportsStore: false,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertMessages } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
OpenAICompletionsCompat,
|
||||
ToolResultMessage,
|
||||
Usage,
|
||||
} from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Model, OpenAICompletionsCompat, ToolResultMessage, Usage } from "../src/types.ts";
|
||||
|
||||
const emptyUsage: Usage = {
|
||||
input: 0,
|
||||
|
|
@ -19,11 +12,7 @@ const emptyUsage: Usage = {
|
|||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
const compat: Omit<
|
||||
Required<OpenAICompletionsCompat>,
|
||||
"deferredToolsMode" | "thinkingTokenBudgetField" | "vllmPriority"
|
||||
> & {
|
||||
deferredToolsMode?: OpenAICompletionsCompat["deferredToolsMode"];
|
||||
const compat: Omit<Required<OpenAICompletionsCompat>, "thinkingTokenBudgetField" | "vllmPriority"> & {
|
||||
thinkingTokenBudgetField?: OpenAICompletionsCompat["thinkingTokenBudgetField"];
|
||||
} = {
|
||||
supportsStore: true,
|
||||
|
|
@ -46,6 +35,8 @@ const compat: Omit<
|
|||
thinkingTokenBudgetField: undefined,
|
||||
supportsStrictMode: true,
|
||||
supportsOpenAIGrammarTools: false,
|
||||
supportsMidConvoSystemMessages: false,
|
||||
supportsMidConvoToolAdditions: false,
|
||||
cacheControlFormat: "anthropic",
|
||||
sendSessionAffinityHeaders: false,
|
||||
sessionAffinityFormat: "openai",
|
||||
|
|
@ -101,14 +92,14 @@ describe("openai-completions convertMessages", () => {
|
|||
timestamp: now,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Read the images", timestamp: now - 2 },
|
||||
assistantMessage,
|
||||
buildToolResult("tool-1", now + 1),
|
||||
buildToolResult("tool-2", now + 2),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const roles = messages.map((message) => message.role);
|
||||
|
|
@ -144,13 +135,13 @@ describe("openai-completions convertMessages", () => {
|
|||
timestamp: now,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistantMessage,
|
||||
buildEmptyToolResult("tool-1", now + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
interface CapturedCompletionsPayload {
|
||||
|
|
@ -62,10 +62,10 @@ function createModel(overrides: Partial<Model<"openai-completions">> = {}): Mode
|
|||
async function captureRequest(model: Model<"openai-completions">) {
|
||||
await streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-key" },
|
||||
).result();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
|
||||
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
|
||||
|
|
@ -53,10 +53,10 @@ async function captureOpenAIResponseHeaders(
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-key", ...options },
|
||||
);
|
||||
|
||||
|
|
@ -85,10 +85,10 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -119,7 +119,7 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
getModel("openai", "gpt-5.4"),
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -134,7 +134,7 @@ describe("openai-responses provider defaults", () => {
|
|||
parameters: Type.Object({ value: Type.String() }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
toolChoice: "required",
|
||||
|
|
@ -167,7 +167,7 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
messages: [{ role: "user", content: "Use a tool.", timestamp: Date.now() }],
|
||||
tools: [
|
||||
{
|
||||
|
|
@ -185,7 +185,7 @@ describe("openai-responses provider defaults", () => {
|
|||
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -229,10 +229,10 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -265,10 +265,10 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
onPayload: (payload) => {
|
||||
|
|
@ -306,10 +306,10 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
getModel("openai", "gpt-5.4"),
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
sessionId,
|
||||
|
|
@ -508,10 +508,10 @@ describe("openai-responses provider defaults", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{ apiKey: "test-key", serviceTier },
|
||||
);
|
||||
|
||||
|
|
@ -540,10 +540,10 @@ describe("openai-responses max_output_tokens compat", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
getModel("openai", "gpt-5.4"),
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
maxTokens: 1024,
|
||||
|
|
@ -577,10 +577,10 @@ describe("openai-responses max_output_tokens compat", () => {
|
|||
|
||||
const stream = streamOpenAIResponses(
|
||||
model,
|
||||
{
|
||||
normalizeContext({
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
maxTokens: 1024,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, ToolResultMessage, Usage } from "../src/types.ts";
|
||||
|
||||
const usage: Usage = {
|
||||
input: 0,
|
||||
|
|
@ -38,13 +38,13 @@ describe("OpenAI Responses convertResponsesMessages empty tool result", () => {
|
|||
timestamp: now,
|
||||
};
|
||||
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistant,
|
||||
buildEmptyToolResult("tool-1", now + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
|
||||
const functionCallOutput = input.find((item) => item.type === "function_call_output") as
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, ToolResultMessage, Usage } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, ToolResultMessage, Usage } from "../src/types.ts";
|
||||
import { shortHash } from "../src/utils/hash.ts";
|
||||
|
||||
const COPILOT_RAW_TOOL_CALL_ID =
|
||||
|
|
@ -44,10 +44,10 @@ describe("OpenAI Responses foreign tool call ID normalization", () => {
|
|||
isError: false,
|
||||
timestamp: Date.now() - 1000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are concise.",
|
||||
messages: [{ role: "user", content: "Use the tool.", timestamp: Date.now() - 3000 }, assistant, toolResult],
|
||||
};
|
||||
});
|
||||
|
||||
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
|
||||
const functionCall = input.find((item) => item.type === "function_call");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ResponseOutputMessage } from "openai/resources/responses/responses.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertResponsesMessages } from "../src/api/openai-responses-shared.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Context, Usage } from "../src/types.ts";
|
||||
import { getModel, normalizeContext } from "../src/compat.ts";
|
||||
import type { AssistantMessage, Usage } from "../src/types.ts";
|
||||
|
||||
const usage: Usage = {
|
||||
input: 0,
|
||||
|
|
@ -29,10 +29,10 @@ describe("OpenAI Responses message ID conversion", () => {
|
|||
stopReason: "stop",
|
||||
timestamp: Date.now() - 1000,
|
||||
};
|
||||
const context: Context = {
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are concise.",
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() - 2000 }, assistant],
|
||||
};
|
||||
});
|
||||
|
||||
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
|
||||
const messageIds = input
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue