Skip to content

Commit 0a3d75d

Browse files
mattappersonclaude
andcommitted
refactor: replace type casts with proper type guards
- Create claude-constants.ts with constants for Claude block types - Create claude-type-guards.ts with isClaudeStyleMessages type guard - Replace `as Record<string, unknown>` casts with proper type narrowing - Remove `as unknown as TTools` double assertion in callModel.ts - Consolidate duplicate normalizeInputToArray functions - Consolidate createTurnContext and createTurnContextWithMutations methods - Add isGeneratorConfig type guard in create-tool.ts - Fix non-null assertion in tool-orchestrator.ts with resultHasError guard - Extend normalizeInputToArray to handle undefined input - Add comprehensive tests for new type guards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 559fd35 commit 0a3d75d

File tree

13 files changed

+1138
-300
lines changed

13 files changed

+1138
-300
lines changed

src/funcs/callModel.ts

Lines changed: 46 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { RequestOptions } from "../lib/sdks.js";
33
import type { Tool, MaxToolRounds } from "../lib/tool-types.js";
44
import type * as models from "../models/index.js";
55

6+
import { isClaudeStyleMessages } from "../lib/claude-type-guards.js";
67
import { ResponseWrapper } from "../lib/response-wrapper.js";
78
import { convertToolsToAPIFormat } from "../lib/tool-executor.js";
89

@@ -25,81 +26,30 @@ export type CallModelTools =
2526
| models.ToolDefinitionJson[]
2627
| models.OpenResponsesRequest["tools"];
2728

29+
// isClaudeStyleMessages is imported from "../lib/claude-type-guards.js"
30+
2831
/**
29-
* Check if input is Anthropic Claude-style messages (ClaudeMessageParam[])
30-
*
31-
* Claude messages have only 'user' or 'assistant' roles (no 'system', 'tool', 'developer')
32-
* and may contain Claude-specific block types in content arrays.
33-
*
34-
* We check for Claude-ONLY features to distinguish from OpenAI format:
35-
* - 'tool_result' blocks (Claude-specific; OpenAI uses role: 'tool')
36-
* - 'image' blocks with 'source' object (Claude-specific structure)
32+
* Check if input is chat-style messages (Message[])
3733
*/
38-
function isClaudeStyleMessages(
39-
input: CallModelInput
40-
): input is models.ClaudeMessageParam[] {
34+
function isChatStyleMessages(input: CallModelInput): input is models.Message[] {
4135
if (!Array.isArray(input) || input.length === 0) {
4236
return false;
4337
}
4438

45-
// Check ALL messages for Claude-specific features
46-
for (const msg of input) {
47-
const m = msg as Record<string, unknown>;
48-
if (!m || !("role" in m) || "type" in m) {
49-
continue;
50-
}
51-
52-
// OpenAI has 'system', 'developer', 'tool' roles that Claude doesn't have
53-
// If we see these roles, it's definitely NOT Claude format
54-
const role = m["role"];
55-
if (role === "system" || role === "developer" || role === "tool") {
56-
return false;
57-
}
58-
59-
const content = m["content"];
60-
if (!Array.isArray(content)) {
61-
continue;
62-
}
63-
64-
for (const block of content) {
65-
const b = block as Record<string, unknown>;
66-
const blockType = b?.["type"];
67-
// 'tool_result' is Claude-specific (OpenAI uses role: 'tool' messages instead)
68-
if (blockType === "tool_result") {
69-
return true;
70-
}
71-
// 'image' with 'source' object is Claude-specific
72-
// OpenAI uses 'image_url' structure instead
73-
if (blockType === "image" && typeof b?.["source"] === "object") {
74-
return true;
75-
}
76-
// 'tool_use' blocks are Claude-specific (OpenAI uses 'tool_calls' array on message)
77-
if (blockType === "tool_use" && typeof b?.["id"] === "string") {
78-
return true;
79-
}
80-
}
39+
const first = input[0];
40+
// Chat-style messages have role but no 'type' field at top level
41+
// Responses-style items have 'type' field (like 'message', 'function_call', etc.)
42+
if (first === null || typeof first !== "object") {
43+
return false;
8144
}
82-
83-
// No Claude-specific features found
84-
// Default to NOT Claude (prefer OpenAI chat format as it's more common)
85-
return false;
45+
return "role" in first && !("type" in first);
8646
}
8747

8848
/**
89-
* Check if input is chat-style messages (Message[])
49+
* Type guard helper: checks if a value is a non-null object
9050
*/
91-
function isChatStyleMessages(input: CallModelInput): input is models.Message[] {
92-
if (!Array.isArray(input)) {
93-
return false;
94-
}
95-
if (input.length === 0) {
96-
return false;
97-
}
98-
99-
const first = input[0] as Record<string, unknown>;
100-
// Chat-style messages have role but no 'type' field at top level
101-
// Responses-style items have 'type' field (like 'message', 'function_call', etc.)
102-
return first && "role" in first && !("type" in first);
51+
function isNonNullObject(value: unknown): value is Record<string, unknown> {
52+
return value !== null && typeof value === "object" && !Array.isArray(value);
10353
}
10454

10555
/**
@@ -108,26 +58,28 @@ function isChatStyleMessages(input: CallModelInput): input is models.Message[] {
10858
function isChatStyleTools(
10959
tools: CallModelTools
11060
): tools is models.ToolDefinitionJson[] {
111-
if (!Array.isArray(tools)) {
61+
if (!Array.isArray(tools) || tools.length === 0) {
11262
return false;
11363
}
114-
if (tools.length === 0) {
64+
65+
const first = tools[0];
66+
if (!isNonNullObject(first)) {
11567
return false;
11668
}
11769

118-
const first = tools[0] as Record<string, unknown>;
11970
// Chat-style tools have nested 'function' property with 'name' inside
12071
// Enhanced tools have 'function' with 'inputSchema'
12172
// Responses-style tools have 'name' at top level
122-
const fn = first?.["function"] as Record<string, unknown> | undefined;
123-
return (
124-
first &&
125-
"function" in first &&
126-
fn !== undefined &&
127-
fn !== null &&
128-
"name" in fn &&
129-
!("inputSchema" in fn)
130-
);
73+
if (!("function" in first)) {
74+
return false;
75+
}
76+
77+
const fn = first.function;
78+
if (!isNonNullObject(fn)) {
79+
return false;
80+
}
81+
82+
return "name" in fn && !("inputSchema" in fn);
13183
}
13284

13385
/**
@@ -463,22 +415,21 @@ export function callModel<TTools extends readonly Tool[] = Tool[]>(
463415
let isChatTools = false;
464416

465417
if (tools && Array.isArray(tools) && tools.length > 0) {
466-
const firstTool = tools[0] as Record<string, unknown>;
467-
const fn = firstTool?.["function"] as Record<string, unknown> | undefined;
468-
isEnhancedTools =
469-
"function" in firstTool &&
470-
fn !== undefined &&
471-
fn !== null &&
472-
"inputSchema" in fn;
418+
const firstTool = tools[0];
419+
if (isNonNullObject(firstTool) && "function" in firstTool) {
420+
const fn = firstTool.function;
421+
if (isNonNullObject(fn) && "inputSchema" in fn) {
422+
isEnhancedTools = true;
423+
}
424+
}
473425
isChatTools = !isEnhancedTools && isChatStyleTools(tools);
474426
}
475427

476-
const enhancedTools = isEnhancedTools ? (tools as Tool[]) : undefined;
477-
478428
// Convert tools to API format based on their type
479429
let apiTools: models.OpenResponsesRequest["tools"];
480-
if (enhancedTools) {
481-
apiTools = convertToolsToAPIFormat(enhancedTools);
430+
if (isEnhancedTools) {
431+
// Tools are enhanced tools with inputSchema - convert to API format
432+
apiTools = convertToolsToAPIFormat(tools as Tool[]);
482433
} else if (isChatTools) {
483434
apiTools = convertChatToResponsesTools(
484435
tools as models.ToolDefinitionJson[]
@@ -507,12 +458,14 @@ export function callModel<TTools extends readonly Tool[] = Tool[]>(
507458
options: options ?? {},
508459
};
509460

510-
// Only pass enhanced tools to wrapper (needed for auto-execution)
511-
// Double assertion needed because TTools is a generic extending readonly Tool[],
512-
// while enhancedTools is Tool[]. TypeScript can't verify the specific TTools subtype
513-
// at runtime, but we know it's safe since we extracted these tools from the input.
514-
if (enhancedTools) {
515-
wrapperOptions.tools = enhancedTools as unknown as TTools;
461+
// Pass enhanced tools to wrapper for auto-execution
462+
// The tools parameter is already typed as TTools from the function signature,
463+
// so we can assign it directly when we've confirmed it matches the enhanced tools pattern
464+
if (isEnhancedTools && tools) {
465+
// Type assertion is necessary here because TypeScript cannot track that
466+
// isEnhancedTools === true implies tools matches TTools (the enhanced tools type).
467+
// We've verified via isNonNullObject checks that tools[0].function.inputSchema exists.
468+
wrapperOptions.tools = tools as TTools;
516469
}
517470

518471
if (maxToolRounds !== undefined) {

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export type {
2626
TypedToolCall,
2727
TypedToolCallUnion,
2828
TurnContext,
29+
TurnChange,
30+
NextTurnParams,
2931
ParsedToolCall,
3032
// Event type inference helpers
3133
InferToolEvent,
@@ -35,4 +37,9 @@ export type {
3537
ToolStreamEvent,
3638
ChatStreamEvent,
3739
EnhancedResponseStreamEvent,
40+
// Builder types
41+
BuildTurnContextOptions,
3842
} from "./lib/tool-types.js";
43+
44+
// Tool helpers
45+
export { normalizeInputToArray, buildTurnContext } from "./lib/tool-types.js";

src/lib/claude-constants.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Claude content block types used in message content arrays
3+
*/
4+
export const ClaudeContentBlockType = {
5+
Text: "text",
6+
Image: "image",
7+
ToolUse: "tool_use",
8+
ToolResult: "tool_result",
9+
} as const;
10+
11+
export type ClaudeContentBlockType =
12+
(typeof ClaudeContentBlockType)[keyof typeof ClaudeContentBlockType];
13+
14+
/**
15+
* Message roles that exist in OpenAI/Chat format but NOT in Claude format.
16+
* Used to distinguish between the two message formats.
17+
*/
18+
export const NonClaudeMessageRole = {
19+
System: "system",
20+
Developer: "developer",
21+
Tool: "tool",
22+
} as const;
23+
24+
export type NonClaudeMessageRole =
25+
(typeof NonClaudeMessageRole)[keyof typeof NonClaudeMessageRole];

src/lib/claude-type-guards.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type * as models from "../models/index.js";
2+
import {
3+
ClaudeContentBlockType,
4+
NonClaudeMessageRole,
5+
} from "./claude-constants.js";
6+
7+
/**
8+
* Type guard: checks if a value is a valid object (not null, not array)
9+
*/
10+
function isRecord(value: unknown): value is Record<string, unknown> {
11+
return value !== null && typeof value === "object" && !Array.isArray(value);
12+
}
13+
14+
/**
15+
* Type guard: checks if a role is an OpenAI/Chat-specific role (not Claude)
16+
*/
17+
function isNonClaudeRole(role: unknown): boolean {
18+
return (
19+
role === NonClaudeMessageRole.System ||
20+
role === NonClaudeMessageRole.Developer ||
21+
role === NonClaudeMessageRole.Tool
22+
);
23+
}
24+
25+
/**
26+
* Type guard: checks if a content block is a Claude tool_result block
27+
*/
28+
function isClaudeToolResultBlock(block: unknown): boolean {
29+
if (!isRecord(block)) return false;
30+
return block["type"] === ClaudeContentBlockType.ToolResult;
31+
}
32+
33+
/**
34+
* Type guard: checks if a content block is a Claude image block with source object
35+
* (Claude uses 'source' object; OpenAI uses 'image_url' structure)
36+
*/
37+
function isClaudeImageBlockWithSource(block: unknown): boolean {
38+
if (!isRecord(block)) return false;
39+
return (
40+
block["type"] === ClaudeContentBlockType.Image &&
41+
"source" in block &&
42+
isRecord(block["source"])
43+
);
44+
}
45+
46+
/**
47+
* Type guard: checks if a content block is a Claude tool_use block with id
48+
* (Claude uses 'tool_use' blocks; OpenAI uses 'tool_calls' array on message)
49+
*/
50+
function isClaudeToolUseBlockWithId(block: unknown): boolean {
51+
if (!isRecord(block)) return false;
52+
return (
53+
block["type"] === ClaudeContentBlockType.ToolUse &&
54+
"id" in block &&
55+
typeof block["id"] === "string"
56+
);
57+
}
58+
59+
/**
60+
* Checks if a message's content array contains Claude-specific blocks
61+
*/
62+
function hasClaudeSpecificBlocks(content: unknown[]): boolean {
63+
for (const block of content) {
64+
if (isClaudeToolResultBlock(block)) return true;
65+
if (isClaudeImageBlockWithSource(block)) return true;
66+
if (isClaudeToolUseBlockWithId(block)) return true;
67+
}
68+
return false;
69+
}
70+
71+
/**
72+
* Type guard: checks if input is Anthropic Claude-style messages
73+
*
74+
* Claude messages have only 'user' or 'assistant' roles (no 'system', 'tool', 'developer')
75+
* and may contain Claude-specific block types in content arrays.
76+
*
77+
* We check for Claude-ONLY features to distinguish from OpenAI format:
78+
* - 'tool_result' blocks (Claude-specific; OpenAI uses role: 'tool')
79+
* - 'image' blocks with 'source' object (Claude-specific structure)
80+
* - 'tool_use' blocks with 'id' (Claude-specific; OpenAI uses 'tool_calls' array on message)
81+
*/
82+
export function isClaudeStyleMessages(
83+
input: unknown
84+
): input is models.ClaudeMessageParam[] {
85+
if (!Array.isArray(input) || input.length === 0) {
86+
return false;
87+
}
88+
89+
for (const msg of input) {
90+
// Skip non-object entries
91+
if (!isRecord(msg)) continue;
92+
93+
// Must have role property
94+
if (!("role" in msg)) continue;
95+
96+
// Claude messages don't have top-level 'type' field
97+
if ("type" in msg) continue;
98+
99+
// OpenAI has 'system', 'developer', 'tool' roles that Claude doesn't have
100+
// If we see these roles, it's definitely NOT Claude format
101+
if (isNonClaudeRole(msg["role"])) {
102+
return false;
103+
}
104+
105+
// Check for Claude-specific content blocks
106+
const content = msg["content"];
107+
if (Array.isArray(content) && hasClaudeSpecificBlocks(content)) {
108+
return true;
109+
}
110+
}
111+
112+
// No Claude-specific features found
113+
// Default to NOT Claude (prefer OpenAI chat format as it's more common)
114+
return false;
115+
}

0 commit comments

Comments
 (0)