icebear commited on
Commit
c3eb32a
·
unverified ·
2 Parent(s): 5db3721759fe9e

Merge pull request #6 from icebear0828/feat/tool-protocol-compat

Browse files
src/routes/chat.ts CHANGED
@@ -117,7 +117,9 @@ export function createChatRoutes(
117
  role: m.role,
118
  content: typeof m.content === "string"
119
  ? m.content
120
- : m.content.filter((p) => p.type === "text" && p.text).map((p) => p.text!).join("\n"),
 
 
121
  })),
122
  model: codexRequest.model,
123
  isStreaming: req.stream,
 
117
  role: m.role,
118
  content: typeof m.content === "string"
119
  ? m.content
120
+ : m.content == null
121
+ ? ""
122
+ : m.content.filter((p) => p.type === "text" && p.text).map((p) => p.text!).join("\n"),
123
  })),
124
  model: codexRequest.model,
125
  isStreaming: req.stream,
src/translation/anthropic-to-codex.ts CHANGED
@@ -23,15 +23,42 @@ function mapThinkingToEffort(
23
 
24
  /**
25
  * Extract text from Anthropic content (string or content block array).
 
26
  */
27
  function flattenContent(
28
- content: string | Array<{ type: string; text?: string }>,
29
  ): string {
30
  if (typeof content === "string") return content;
31
- return content
32
- .filter((b) => b.type === "text" && b.text)
33
- .map((b) => b.text!)
34
- .join("\n");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  }
36
 
37
  /**
 
23
 
24
  /**
25
  * Extract text from Anthropic content (string or content block array).
26
+ * Flattens tool_use/tool_result blocks into readable text for Codex.
27
  */
28
  function flattenContent(
29
+ content: string | Array<Record<string, unknown>>,
30
  ): string {
31
  if (typeof content === "string") return content;
32
+ const parts: string[] = [];
33
+ for (const block of content) {
34
+ if (block.type === "text" && typeof block.text === "string") {
35
+ parts.push(block.text);
36
+ } else if (block.type === "tool_use") {
37
+ const name = typeof block.name === "string" ? block.name : "unknown";
38
+ let inputStr: string;
39
+ try {
40
+ inputStr = JSON.stringify(block.input, null, 2);
41
+ } catch {
42
+ inputStr = String(block.input);
43
+ }
44
+ parts.push(`[Tool Call: ${name}(${inputStr})]`);
45
+ } else if (block.type === "tool_result") {
46
+ const id =
47
+ typeof block.tool_use_id === "string" ? block.tool_use_id : "unknown";
48
+ let text = "";
49
+ if (typeof block.content === "string") {
50
+ text = block.content;
51
+ } else if (Array.isArray(block.content)) {
52
+ text = (block.content as Array<{ text?: string }>)
53
+ .filter((b) => typeof b.text === "string")
54
+ .map((b) => b.text!)
55
+ .join("\n");
56
+ }
57
+ const prefix = block.is_error ? "Tool Error" : "Tool Result";
58
+ parts.push(`[${prefix} (${id})]: ${text}`);
59
+ }
60
+ }
61
+ return parts.join("\n");
62
  }
63
 
64
  /**
src/translation/gemini-to-codex.ts CHANGED
@@ -16,14 +16,40 @@ import { buildInstructions, budgetToEffort } from "./shared-utils.js";
16
 
17
  /**
18
  * Extract text from Gemini content parts.
 
19
  */
20
  function flattenParts(
21
- parts: Array<{ text?: string; thought?: boolean }>,
 
 
 
 
 
22
  ): string {
23
- return parts
24
- .filter((p) => p.text && !p.thought)
25
- .map((p) => p.text!)
26
- .join("\n");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
  /**
 
16
 
17
  /**
18
  * Extract text from Gemini content parts.
19
+ * Flattens functionCall/functionResponse parts into readable text for Codex.
20
  */
21
  function flattenParts(
22
+ parts: Array<{
23
+ text?: string;
24
+ thought?: boolean;
25
+ functionCall?: { name: string; args?: Record<string, unknown> };
26
+ functionResponse?: { name: string; response?: Record<string, unknown> };
27
+ }>,
28
  ): string {
29
+ const textParts: string[] = [];
30
+ for (const p of parts) {
31
+ if (p.thought) continue;
32
+ if (p.text) {
33
+ textParts.push(p.text);
34
+ } else if (p.functionCall) {
35
+ let args: string;
36
+ try {
37
+ args = JSON.stringify(p.functionCall.args ?? {}, null, 2);
38
+ } catch {
39
+ args = String(p.functionCall.args);
40
+ }
41
+ textParts.push(`[Tool Call: ${p.functionCall.name}(${args})]`);
42
+ } else if (p.functionResponse) {
43
+ let resp: string;
44
+ try {
45
+ resp = JSON.stringify(p.functionResponse.response ?? {}, null, 2);
46
+ } catch {
47
+ resp = String(p.functionResponse.response);
48
+ }
49
+ textParts.push(`[Tool Result (${p.functionResponse.name})]: ${resp}`);
50
+ }
51
+ }
52
+ return textParts.join("\n");
53
  }
54
 
55
  /**
src/translation/openai-to-codex.ts CHANGED
@@ -11,8 +11,9 @@ import { resolveModelId, getModelInfo } from "../routes/models.js";
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions } from "./shared-utils.js";
13
 
14
- /** Extract plain text from content (string or array of content parts). */
15
  function extractText(content: ChatMessage["content"]): string {
 
16
  if (typeof content === "string") return content;
17
  return content
18
  .filter((p) => p.type === "text" && p.text)
@@ -20,6 +21,36 @@ function extractText(content: ChatMessage["content"]): string {
20
  .join("\n");
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  /**
24
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
25
  *
@@ -43,13 +74,33 @@ export function translateToCodexRequest(
43
  const instructions = buildInstructions(userInstructions);
44
 
45
  // Build input items from non-system messages
 
46
  const input: CodexInputItem[] = [];
47
  for (const msg of req.messages) {
48
  if (msg.role === "system" || msg.role === "developer") continue;
49
- input.push({
50
- role: msg.role as "user" | "assistant",
51
- content: extractText(msg.content),
52
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  // Ensure at least one input message
 
11
  import { getConfig } from "../config.js";
12
  import { buildInstructions } from "./shared-utils.js";
13
 
14
+ /** Extract plain text from content (string, array, null, or undefined). */
15
  function extractText(content: ChatMessage["content"]): string {
16
+ if (content == null) return "";
17
  if (typeof content === "string") return content;
18
  return content
19
  .filter((p) => p.type === "text" && p.text)
 
21
  .join("\n");
22
  }
23
 
24
+ /** Flatten tool_calls array into human-readable text. */
25
+ function flattenToolCalls(
26
+ toolCalls: NonNullable<ChatMessage["tool_calls"]>,
27
+ ): string {
28
+ return toolCalls
29
+ .map((tc) => {
30
+ let args = tc.function.arguments;
31
+ try {
32
+ args = JSON.stringify(JSON.parse(args), null, 2);
33
+ } catch {
34
+ /* keep raw string */
35
+ }
36
+ return `[Tool Call: ${tc.function.name}(${args})]`;
37
+ })
38
+ .join("\n");
39
+ }
40
+
41
+ /** Flatten a legacy function_call into human-readable text. */
42
+ function flattenFunctionCall(
43
+ fc: NonNullable<ChatMessage["function_call"]>,
44
+ ): string {
45
+ let args = fc.arguments;
46
+ try {
47
+ args = JSON.stringify(JSON.parse(args), null, 2);
48
+ } catch {
49
+ /* keep raw string */
50
+ }
51
+ return `[Tool Call: ${fc.name}(${args})]`;
52
+ }
53
+
54
  /**
55
  * Convert a ChatCompletionRequest to a CodexResponsesRequest.
56
  *
 
74
  const instructions = buildInstructions(userInstructions);
75
 
76
  // Build input items from non-system messages
77
+ // Handles new format (tool/tool_calls) and legacy format (function/function_call)
78
  const input: CodexInputItem[] = [];
79
  for (const msg of req.messages) {
80
  if (msg.role === "system" || msg.role === "developer") continue;
81
+
82
+ if (msg.role === "assistant") {
83
+ const parts: string[] = [];
84
+ const text = extractText(msg.content);
85
+ if (text) parts.push(text);
86
+ if (msg.tool_calls?.length) parts.push(flattenToolCalls(msg.tool_calls));
87
+ if (msg.function_call) parts.push(flattenFunctionCall(msg.function_call));
88
+ input.push({ role: "assistant", content: parts.join("\n") });
89
+ } else if (msg.role === "tool") {
90
+ const name = msg.name ?? msg.tool_call_id ?? "unknown";
91
+ input.push({
92
+ role: "user",
93
+ content: `[Tool Result (${name})]: ${extractText(msg.content)}`,
94
+ });
95
+ } else if (msg.role === "function") {
96
+ const name = msg.name ?? "unknown";
97
+ input.push({
98
+ role: "user",
99
+ content: `[Tool Result (${name})]: ${extractText(msg.content)}`,
100
+ });
101
+ } else {
102
+ input.push({ role: "user", content: extractText(msg.content) });
103
+ }
104
  }
105
 
106
  // Ensure at least one input message
src/types/anthropic.ts CHANGED
@@ -19,9 +19,25 @@ const AnthropicImageContentSchema = z.object({
19
  }),
20
  });
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  const AnthropicContentBlockSchema = z.discriminatedUnion("type", [
23
  AnthropicTextContentSchema,
24
  AnthropicImageContentSchema,
 
 
25
  ]);
26
 
27
  const AnthropicContentSchema = z.union([
@@ -63,6 +79,17 @@ export const AnthropicMessagesRequestSchema = z.object({
63
  thinking: z
64
  .union([AnthropicThinkingEnabledSchema, AnthropicThinkingDisabledSchema])
65
  .optional(),
 
 
 
 
 
 
 
 
 
 
 
66
  });
67
 
68
  export type AnthropicMessagesRequest = z.infer<
@@ -72,9 +99,12 @@ export type AnthropicMessagesRequest = z.infer<
72
  // --- Response ---
73
 
74
  export interface AnthropicContentBlock {
75
- type: "text" | "thinking";
76
  text?: string;
77
  thinking?: string;
 
 
 
78
  }
79
 
80
  export interface AnthropicUsage {
@@ -88,7 +118,7 @@ export interface AnthropicMessagesResponse {
88
  role: "assistant";
89
  content: AnthropicContentBlock[];
90
  model: string;
91
- stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | null;
92
  stop_sequence: string | null;
93
  usage: AnthropicUsage;
94
  }
 
19
  }),
20
  });
21
 
22
+ const AnthropicToolUseContentSchema = z.object({
23
+ type: z.literal("tool_use"),
24
+ id: z.string(),
25
+ name: z.string(),
26
+ input: z.record(z.unknown()),
27
+ });
28
+
29
+ const AnthropicToolResultContentSchema = z.object({
30
+ type: z.literal("tool_result"),
31
+ tool_use_id: z.string(),
32
+ content: z.union([z.string(), z.array(AnthropicTextContentSchema)]).optional(),
33
+ is_error: z.boolean().optional(),
34
+ });
35
+
36
  const AnthropicContentBlockSchema = z.discriminatedUnion("type", [
37
  AnthropicTextContentSchema,
38
  AnthropicImageContentSchema,
39
+ AnthropicToolUseContentSchema,
40
+ AnthropicToolResultContentSchema,
41
  ]);
42
 
43
  const AnthropicContentSchema = z.union([
 
79
  thinking: z
80
  .union([AnthropicThinkingEnabledSchema, AnthropicThinkingDisabledSchema])
81
  .optional(),
82
+ // Tool-related fields (accepted for compatibility, not forwarded to Codex)
83
+ tools: z.array(z.object({
84
+ name: z.string(),
85
+ description: z.string().optional(),
86
+ input_schema: z.record(z.unknown()).optional(),
87
+ }).passthrough()).optional(),
88
+ tool_choice: z.union([
89
+ z.object({ type: z.literal("auto") }),
90
+ z.object({ type: z.literal("any") }),
91
+ z.object({ type: z.literal("tool"), name: z.string() }),
92
+ ]).optional(),
93
  });
94
 
95
  export type AnthropicMessagesRequest = z.infer<
 
99
  // --- Response ---
100
 
101
  export interface AnthropicContentBlock {
102
+ type: "text" | "thinking" | "tool_use";
103
  text?: string;
104
  thinking?: string;
105
+ id?: string;
106
+ name?: string;
107
+ input?: Record<string, unknown>;
108
  }
109
 
110
  export interface AnthropicUsage {
 
118
  role: "assistant";
119
  content: AnthropicContentBlock[];
120
  model: string;
121
+ stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null;
122
  stop_sequence: string | null;
123
  usage: AnthropicUsage;
124
  }
src/types/gemini.ts CHANGED
@@ -8,6 +8,15 @@ import { z } from "zod";
8
  const GeminiPartSchema = z.object({
9
  text: z.string().optional(),
10
  thought: z.boolean().optional(),
 
 
 
 
 
 
 
 
 
11
  });
12
 
13
  const GeminiContentSchema = z.object({
@@ -32,6 +41,20 @@ export const GeminiGenerateContentRequestSchema = z.object({
32
  contents: z.array(GeminiContentSchema).min(1),
33
  systemInstruction: GeminiContentSchema.optional(),
34
  generationConfig: GeminiGenerationConfigSchema.optional(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  });
36
 
37
  export type GeminiGenerateContentRequest = z.infer<
 
8
  const GeminiPartSchema = z.object({
9
  text: z.string().optional(),
10
  thought: z.boolean().optional(),
11
+ // Function calling fields (accepted for compatibility, not forwarded to Codex)
12
+ functionCall: z.object({
13
+ name: z.string(),
14
+ args: z.record(z.unknown()).optional(),
15
+ }).optional(),
16
+ functionResponse: z.object({
17
+ name: z.string(),
18
+ response: z.record(z.unknown()).optional(),
19
+ }).optional(),
20
  });
21
 
22
  const GeminiContentSchema = z.object({
 
41
  contents: z.array(GeminiContentSchema).min(1),
42
  systemInstruction: GeminiContentSchema.optional(),
43
  generationConfig: GeminiGenerationConfigSchema.optional(),
44
+ // Tool-related fields (accepted for compatibility, not forwarded to Codex)
45
+ tools: z.array(z.object({
46
+ functionDeclarations: z.array(z.object({
47
+ name: z.string(),
48
+ description: z.string().optional(),
49
+ parameters: z.record(z.unknown()).optional(),
50
+ })).optional(),
51
+ }).passthrough()).optional(),
52
+ toolConfig: z.object({
53
+ functionCallingConfig: z.object({
54
+ mode: z.enum(["AUTO", "NONE", "ANY"]).optional(),
55
+ allowedFunctionNames: z.array(z.string()).optional(),
56
+ }).optional(),
57
+ }).optional(),
58
  });
59
 
60
  export type GeminiGenerateContentRequest = z.infer<
src/types/openai.ts CHANGED
@@ -11,9 +11,24 @@ const ContentPartSchema = z.object({
11
  }).passthrough();
12
 
13
  export const ChatMessageSchema = z.object({
14
- role: z.enum(["system", "developer", "user", "assistant"]),
15
- content: z.union([z.string(), z.array(ContentPartSchema)]),
16
  name: z.string().optional(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  });
18
 
19
  export const ChatCompletionRequestSchema = z.object({
@@ -30,6 +45,30 @@ export const ChatCompletionRequestSchema = z.object({
30
  user: z.string().optional(),
31
  // Codex-specific extensions
32
  reasoning_effort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  });
34
 
35
  export type ChatMessage = z.infer<typeof ChatMessageSchema>;
@@ -41,9 +80,9 @@ export interface ChatCompletionChoice {
41
  index: number;
42
  message: {
43
  role: "assistant";
44
- content: string;
45
  };
46
- finish_reason: "stop" | "length" | null;
47
  }
48
 
49
  export interface ChatCompletionUsage {
@@ -65,13 +104,13 @@ export interface ChatCompletionResponse {
65
 
66
  export interface ChatCompletionChunkDelta {
67
  role?: "assistant";
68
- content?: string;
69
  }
70
 
71
  export interface ChatCompletionChunkChoice {
72
  index: number;
73
  delta: ChatCompletionChunkDelta;
74
- finish_reason: "stop" | "length" | null;
75
  }
76
 
77
  export interface ChatCompletionChunk {
 
11
  }).passthrough();
12
 
13
  export const ChatMessageSchema = z.object({
14
+ role: z.enum(["system", "developer", "user", "assistant", "tool", "function"]),
15
+ content: z.union([z.string(), z.array(ContentPartSchema)]).nullable().optional(),
16
  name: z.string().optional(),
17
+ // New format: tool_calls (array, on assistant messages)
18
+ tool_calls: z.array(z.object({
19
+ id: z.string(),
20
+ type: z.literal("function"),
21
+ function: z.object({
22
+ name: z.string(),
23
+ arguments: z.string(),
24
+ }),
25
+ })).optional(),
26
+ tool_call_id: z.string().optional(),
27
+ // Legacy format: function_call (single object, on assistant messages)
28
+ function_call: z.object({
29
+ name: z.string(),
30
+ arguments: z.string(),
31
+ }).optional(),
32
  });
33
 
34
  export const ChatCompletionRequestSchema = z.object({
 
45
  user: z.string().optional(),
46
  // Codex-specific extensions
47
  reasoning_effort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
48
+ // New tool format (accepted for compatibility, not forwarded to Codex)
49
+ tools: z.array(z.object({
50
+ type: z.literal("function"),
51
+ function: z.object({
52
+ name: z.string(),
53
+ description: z.string().optional(),
54
+ parameters: z.record(z.unknown()).optional(),
55
+ }),
56
+ })).optional(),
57
+ tool_choice: z.union([
58
+ z.enum(["none", "auto", "required"]),
59
+ z.object({ type: z.literal("function"), function: z.object({ name: z.string() }) }),
60
+ ]).optional(),
61
+ parallel_tool_calls: z.boolean().optional(),
62
+ // Legacy function format (accepted for compatibility, not forwarded to Codex)
63
+ functions: z.array(z.object({
64
+ name: z.string(),
65
+ description: z.string().optional(),
66
+ parameters: z.record(z.unknown()).optional(),
67
+ })).optional(),
68
+ function_call: z.union([
69
+ z.enum(["none", "auto"]),
70
+ z.object({ name: z.string() }),
71
+ ]).optional(),
72
  });
73
 
74
  export type ChatMessage = z.infer<typeof ChatMessageSchema>;
 
80
  index: number;
81
  message: {
82
  role: "assistant";
83
+ content: string | null;
84
  };
85
+ finish_reason: "stop" | "length" | "tool_calls" | "function_call" | null;
86
  }
87
 
88
  export interface ChatCompletionUsage {
 
104
 
105
  export interface ChatCompletionChunkDelta {
106
  role?: "assistant";
107
+ content?: string | null;
108
  }
109
 
110
  export interface ChatCompletionChunkChoice {
111
  index: number;
112
  delta: ChatCompletionChunkDelta;
113
+ finish_reason: "stop" | "length" | "tool_calls" | "function_call" | null;
114
  }
115
 
116
  export interface ChatCompletionChunk {