Skip to content

Commit 5f80a32

Browse files
tylergibbs1claude
andcommitted
Add hosted tools, fix toolChoice for Responses API, add previousResponseId
- HostedTool type and AgentTool union (FunctionTool | HostedTool) with type guards - Built-in tool factories: webSearchTool, codeInterpreterTool, mcpTool, imageGenerationTool - Fix toolChoice serialization for Responses API ({ type, function: { name } } → { type, name }) - Hosted tool defs pass through to Responses API; Chat Completions rejects with clear error - Agent.tools and SessionConfig.tools widened to AgentTool[] (backward compatible) - ModelRequest.previousResponseId / ModelResponse.responseId / RunResult.responseId - AzureResponsesModel.store config (default false), gates previous_response_id forwarding - 30 new unit tests, 7 integration tests (6 pass, 1 skip for model support) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6828dd9 commit 5f80a32

15 files changed

Lines changed: 1024 additions & 16 deletions

src/azure/chat-completions-model.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ContentFilterError, ModelError } from "../core/errors";
1+
import { ContentFilterError, ModelError, StratusError } from "../core/errors";
22
import type {
33
FinishReason,
44
Model,
@@ -8,7 +8,7 @@ import type {
88
StreamEvent,
99
UsageInfo,
1010
} from "../core/model";
11-
import type { ChatMessage, ToolCall } from "../core/types";
11+
import type { ChatMessage, ToolCall, ToolDefinition } from "../core/types";
1212
import { resolveChatCompletionsUrl } from "./endpoint";
1313
import { parseSSE } from "./sse-parser";
1414

@@ -161,7 +161,14 @@ export class AzureChatCompletionsModel implements Model {
161161
}
162162

163163
if (request.tools && request.tools.length > 0) {
164-
body.tools = request.tools;
164+
for (const tool of request.tools) {
165+
if (!("function" in tool)) {
166+
throw new StratusError(
167+
"Hosted tools (web_search, code_interpreter, mcp, image_generation) are not supported by the Chat Completions API. Use AzureResponsesModel instead.",
168+
);
169+
}
170+
}
171+
body.tools = request.tools as ToolDefinition[];
165172
}
166173

167174
if (request.responseFormat) {

src/azure/responses-model.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ContentPart,
1414
ResponseFormat,
1515
ToolCall,
16+
ToolChoice,
1617
ToolDefinition,
1718
} from "../core/types";
1819
import { resolveResponsesUrl } from "./endpoint";
@@ -23,6 +24,7 @@ export interface AzureResponsesModelConfig {
2324
apiKey: string;
2425
deployment: string;
2526
apiVersion?: string;
27+
store?: boolean;
2628
}
2729

2830
const DEFAULT_API_VERSION = "2025-04-01-preview";
@@ -31,10 +33,12 @@ export class AzureResponsesModel implements Model {
3133
private readonly url: string;
3234
private readonly apiKey: string;
3335
private readonly deployment: string;
36+
private readonly store: boolean;
3437

3538
constructor(config: AzureResponsesModelConfig) {
3639
this.apiKey = config.apiKey;
3740
this.deployment = config.deployment;
41+
this.store = config.store ?? false;
3842
this.url = resolveResponsesUrl(
3943
config.endpoint,
4044
config.apiVersion ?? DEFAULT_API_VERSION,
@@ -67,6 +71,7 @@ export class AzureResponsesModel implements Model {
6771
const toolCalls = new Map<string, { callId: string; name: string; arguments: string }>();
6872
let usage: UsageInfo | undefined;
6973
let finishReason: FinishReason | undefined;
74+
let responseId: string | undefined;
7075

7176
for await (const data of parseSSE(response.body)) {
7277
let event: ResponsesStreamEvent;
@@ -124,6 +129,9 @@ export class AzureResponsesModel implements Model {
124129
}
125130
case "response.completed": {
126131
const resp = event.response;
132+
if (resp?.id) {
133+
responseId = resp.id;
134+
}
127135
if (resp?.usage) {
128136
usage = {
129137
promptTokens: resp.usage.input_tokens,
@@ -156,6 +164,7 @@ export class AzureResponsesModel implements Model {
156164
toolCalls: finalToolCalls,
157165
usage,
158166
finishReason,
167+
responseId,
159168
},
160169
};
161170
}
@@ -166,7 +175,7 @@ export class AzureResponsesModel implements Model {
166175
): Record<string, unknown> {
167176
const body: Record<string, unknown> = {
168177
model: this.deployment,
169-
store: false,
178+
store: this.store,
170179
};
171180

172181
const { instructions, input } = convertMessages(request.messages);
@@ -182,21 +191,32 @@ export class AzureResponsesModel implements Model {
182191
}
183192

184193
if (request.tools && request.tools.length > 0) {
185-
body.tools = request.tools.map(flattenToolDefinition);
194+
body.tools = request.tools.map((def) => {
195+
if (isFunctionToolDefinition(def)) {
196+
return flattenToolDefinition(def);
197+
}
198+
// Hosted tool definitions pass through as-is
199+
return def;
200+
});
186201
}
187202

188203
if (request.responseFormat) {
189204
body.text = convertResponseFormat(request.responseFormat);
190205
}
191206

207+
// Only send previous_response_id when store is enabled (API needs to persist responses)
208+
if (this.store && request.previousResponseId) {
209+
body.previous_response_id = request.previousResponseId;
210+
}
211+
192212
const s = request.modelSettings;
193213
if (s) {
194214
if (s.temperature !== undefined) body.temperature = s.temperature;
195215
if (s.topP !== undefined) body.top_p = s.topP;
196216
if (s.maxTokens !== undefined) body.max_output_tokens = s.maxTokens;
197217
if (s.maxCompletionTokens !== undefined)
198218
body.max_output_tokens = s.maxCompletionTokens;
199-
if (s.toolChoice !== undefined) body.tool_choice = s.toolChoice;
219+
if (s.toolChoice !== undefined) body.tool_choice = convertToolChoice(s.toolChoice);
200220
if (s.parallelToolCalls !== undefined) body.parallel_tool_calls = s.parallelToolCalls;
201221
if (s.reasoningEffort !== undefined)
202222
body.reasoning = { effort: s.reasoningEffort };
@@ -277,6 +297,7 @@ export class AzureResponsesModel implements Model {
277297
toolCalls,
278298
usage: json.usage ? parseResponsesUsage(json.usage) : undefined,
279299
finishReason: "length",
300+
responseId: json.id,
280301
};
281302
}
282303

@@ -295,6 +316,7 @@ export class AzureResponsesModel implements Model {
295316
toolCalls,
296317
usage,
297318
finishReason,
319+
responseId: json.id,
298320
};
299321
}
300322
}
@@ -417,6 +439,21 @@ function convertUserContent(
417439
});
418440
}
419441

442+
function isFunctionToolDefinition(
443+
def: ToolDefinition | Record<string, unknown>,
444+
): def is ToolDefinition {
445+
return "function" in def && typeof (def as ToolDefinition).function === "object";
446+
}
447+
448+
function convertToolChoice(
449+
toolChoice: ToolChoice,
450+
): string | { type: string; name: string } {
451+
if (typeof toolChoice === "string") return toolChoice;
452+
// Chat Completions format: { type: "function", function: { name } }
453+
// Responses API format: { type: "function", name }
454+
return { type: toolChoice.type, name: toolChoice.function.name };
455+
}
456+
420457
function flattenToolDefinition(
421458
def: ToolDefinition,
422459
): Record<string, unknown> {
@@ -459,6 +496,7 @@ function mapStatus(status: string | undefined): FinishReason {
459496
// --- Responses API types ---
460497

461498
interface ResponsesApiResponse {
499+
id?: string;
462500
status: string;
463501
output?: ResponsesOutputItem[];
464502
usage?: ResponsesUsage;
@@ -504,4 +542,4 @@ type ResponsesStreamEvent =
504542
| { type: "response.output_item.added"; item?: ResponsesStreamItem }
505543
| { type: "response.function_call_arguments.delta"; item_id?: string; delta?: string }
506544
| { type: "response.output_item.done"; item?: ResponsesStreamItem }
507-
| { type: "response.completed"; response?: { status?: string; usage?: ResponsesUsage } };
545+
| { type: "response.completed"; response?: { id?: string; status?: string; usage?: ResponsesUsage } };

src/core/agent.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type Handoff, handoff as normalizeHandoff } from "./handoff";
44
import type { AgentHooks } from "./hooks";
55
import type { Model } from "./model";
66
import type { SubAgent } from "./subagent";
7+
import type { AgentTool } from "./hosted-tool";
78
import type { FunctionTool } from "./tool";
89
import type { ModelSettings, ResponseFormat, ToolUseBehavior } from "./types";
910
import { zodToJsonSchema } from "./utils/zod";
@@ -16,7 +17,7 @@ export interface AgentConfig<TContext, TOutput = undefined> {
1617
name: string;
1718
instructions?: Instructions<TContext>;
1819
model?: Model;
19-
tools?: FunctionTool[];
20+
tools?: AgentTool[];
2021
subagents?: SubAgent[];
2122
modelSettings?: ModelSettings;
2223
responseFormat?: ResponseFormat;
@@ -33,7 +34,7 @@ export class Agent<TContext = unknown, TOutput = undefined> {
3334
readonly name: string;
3435
readonly instructions?: Instructions<TContext>;
3536
readonly model?: Model;
36-
readonly tools: FunctionTool[];
37+
readonly tools: AgentTool[];
3738
readonly subagents: SubAgent[];
3839
readonly modelSettings?: ModelSettings;
3940
readonly responseFormat?: ResponseFormat;

src/core/builtin-tools.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { HostedTool } from "./hosted-tool";
2+
3+
export interface WebSearchToolConfig {
4+
userLocation?: {
5+
type: "approximate";
6+
city?: string;
7+
state?: string;
8+
country?: string;
9+
region?: string;
10+
};
11+
searchContextSize?: "low" | "medium" | "high";
12+
}
13+
14+
export function webSearchTool(config?: WebSearchToolConfig): HostedTool {
15+
const definition: Record<string, unknown> = {
16+
type: "web_search_preview",
17+
};
18+
if (config?.userLocation) {
19+
definition.user_location = config.userLocation;
20+
}
21+
if (config?.searchContextSize) {
22+
definition.search_context_size = config.searchContextSize;
23+
}
24+
return {
25+
type: "hosted",
26+
name: "web_search_preview",
27+
definition,
28+
};
29+
}
30+
31+
export interface CodeInterpreterToolConfig {
32+
container?: {
33+
type: "auto" | string;
34+
};
35+
}
36+
37+
export function codeInterpreterTool(config?: CodeInterpreterToolConfig): HostedTool {
38+
return {
39+
type: "hosted",
40+
name: "code_interpreter",
41+
definition: {
42+
type: "code_interpreter",
43+
container: config?.container ?? { type: "auto" },
44+
},
45+
};
46+
}
47+
48+
export interface McpToolConfig {
49+
serverLabel: string;
50+
serverUrl: string;
51+
requireApproval?: "always" | "never" | { always?: string[]; never?: string[] };
52+
headers?: Record<string, string>;
53+
}
54+
55+
export function mcpTool(config: McpToolConfig): HostedTool {
56+
const definition: Record<string, unknown> = {
57+
type: "mcp",
58+
server_label: config.serverLabel,
59+
server_url: config.serverUrl,
60+
};
61+
if (config.requireApproval !== undefined) {
62+
definition.require_approval = config.requireApproval;
63+
}
64+
if (config.headers) {
65+
definition.headers = config.headers;
66+
}
67+
return {
68+
type: "hosted",
69+
name: `mcp:${config.serverLabel}`,
70+
definition,
71+
};
72+
}
73+
74+
export function imageGenerationTool(): HostedTool {
75+
return {
76+
type: "hosted",
77+
name: "image_generation",
78+
definition: {
79+
type: "image_generation",
80+
},
81+
};
82+
}

src/core/hosted-tool.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { FunctionTool } from "./tool";
2+
3+
export interface HostedTool {
4+
type: "hosted";
5+
name: string;
6+
definition: Record<string, unknown>;
7+
}
8+
9+
export type AgentTool = FunctionTool | HostedTool;
10+
11+
export function isHostedTool(tool: AgentTool): tool is HostedTool {
12+
return tool.type === "hosted";
13+
}
14+
15+
export function isFunctionTool(tool: AgentTool): tool is FunctionTool {
16+
return tool.type === "function";
17+
}

src/core/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export type { SessionConfig, SessionSnapshot } from "./session";
1313
export { tool, toolToDefinition } from "./tool";
1414
export type { FunctionTool, ToolExecuteOptions } from "./tool";
1515

16+
export { isHostedTool, isFunctionTool } from "./hosted-tool";
17+
export type { HostedTool, AgentTool } from "./hosted-tool";
18+
19+
export { webSearchTool, codeInterpreterTool, mcpTool, imageGenerationTool } from "./builtin-tools";
20+
export type { WebSearchToolConfig, CodeInterpreterToolConfig, McpToolConfig } from "./builtin-tools";
21+
1622
export { TodoList, todoTool } from "./todo";
1723
export type { Todo, TodoStatus, TodoUpdateListener } from "./todo";
1824

src/core/model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { ChatMessage, ModelSettings, ResponseFormat, ToolCall, ToolDefiniti
22

33
export interface ModelRequest {
44
messages: ChatMessage[];
5-
tools?: ToolDefinition[];
5+
tools?: (ToolDefinition | Record<string, unknown>)[];
66
modelSettings?: ModelSettings;
77
responseFormat?: ResponseFormat;
8+
previousResponseId?: string;
89
}
910

1011
export interface UsageInfo {
@@ -23,6 +24,7 @@ export interface ModelResponse {
2324
toolCalls: ToolCall[];
2425
usage?: UsageInfo;
2526
finishReason?: FinishReason;
27+
responseId?: string;
2628
}
2729

2830
export type StreamEvent =

src/core/result.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface RunResultOptions<TOutput = undefined> {
1111
finishReason?: FinishReason;
1212
numTurns?: number;
1313
totalCostUsd?: number;
14+
responseId?: string;
1415
}
1516

1617
export class RunResult<TOutput = undefined> {
@@ -22,6 +23,7 @@ export class RunResult<TOutput = undefined> {
2223
readonly finishReason?: FinishReason;
2324
readonly numTurns: number;
2425
readonly totalCostUsd: number;
26+
readonly responseId?: string;
2527

2628
constructor(opts: RunResultOptions<TOutput>) {
2729
this.output = opts.output;
@@ -32,5 +34,6 @@ export class RunResult<TOutput = undefined> {
3234
this.finishReason = opts.finishReason;
3335
this.numTurns = opts.numTurns ?? 0;
3436
this.totalCostUsd = opts.totalCostUsd ?? 0;
37+
this.responseId = opts.responseId;
3538
}
3639
}

0 commit comments

Comments
 (0)