fix(translators): normalize TitleCase tool names for non-Anthropic models (#7926)

* fix(translators): normalize TitleCase tool names for non-Anthropic models

* fix(translator): parse XML <invoke> blocks in OpenAI→Claude response translator

* fix(gemini-web): mark gemini-3.1-flash-lite as toolCalling: false

* fix(gemini-web): mark all models as toolCalling: false, add to no-tools combo

* fix(web-providers): mark all web-cookie models as toolCalling: false

* fix(nvidia): disable tool calling on models that can't handle it

* fix(lint): drop explicit any annotation on extractXmlInvokeBlocks state param

Removes the no-explicit-any violation introduced alongside the TitleCase
tool-name normalization work so the merged branch stays lint-clean
(state stays implicitly typed like its sibling helpers in this file).

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Navaneet Ramabadran
2026-07-21 09:40:57 -07:00
committed by GitHub
parent 55549bfe5a
commit eab59d4048
22 changed files with 437 additions and 218 deletions

View File

@@ -9,12 +9,12 @@ export const adapta_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "adapta-one", name: "Adapta ONE (Auto)" },
{ id: "adapta-gpt", name: "GPT-5 (via Adapta)" },
{ id: "adapta-claude", name: "Claude Sonnet 4.6 (via Adapta)" },
{ id: "adapta-gemini", name: "Gemini 2.5 Pro (via Adapta)" },
{ id: "adapta-grok", name: "Grok 4 (via Adapta)" },
{ id: "adapta-deepseek", name: "DeepSeek R2 (via Adapta)" },
{ id: "adapta-llama", name: "Llama 4 (via Adapta)" },
{ id: "adapta-one", name: "Adapta ONE (Auto)", toolCalling: false },
{ id: "adapta-gpt", name: "GPT-5 (via Adapta)", toolCalling: false },
{ id: "adapta-claude", name: "Claude Sonnet 4.6 (via Adapta)", toolCalling: false },
{ id: "adapta-gemini", name: "Gemini 2.5 Pro (via Adapta)", toolCalling: false },
{ id: "adapta-grok", name: "Grok 4 (via Adapta)", toolCalling: false },
{ id: "adapta-deepseek", name: "DeepSeek R2 (via Adapta)", toolCalling: false },
{ id: "adapta-llama", name: "Llama 4 (via Adapta)", toolCalling: false },
],
};

View File

@@ -9,11 +9,11 @@ export const blackbox_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
{ id: "gpt-4", name: "GPT-4" },
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
{ id: "claude-3-opus", name: "Claude 3 Opus" },
{ id: "claude-3-sonnet", name: "Claude 3 Sonnet" },
{ id: "gemini-pro", name: "Gemini Pro" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo", toolCalling: false },
{ id: "gpt-4", name: "GPT-4", toolCalling: false },
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo", toolCalling: false },
{ id: "claude-3-opus", name: "Claude 3 Opus", toolCalling: false },
{ id: "claude-3-sonnet", name: "Claude 3 Sonnet", toolCalling: false },
{ id: "gemini-pro", name: "Gemini Pro", toolCalling: false },
],
};

View File

@@ -9,12 +9,12 @@ export const chatgpt_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "gpt-5.6-pro", name: "GPT-5.6 Pro" }, // pro tier only, standard effort
{ id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking" }, // plus, pro tier
{ id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended" }, // pro tier only, extended effort
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, // pro tier only, standard effort
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" }, // plus, pro tier
{ id: "gpt-5.5", name: "GPT-5.5 Instant" }, // free, plus, pro tier
{ id: "o3", name: "o3" }, // plus ~ tier
{ id: "gpt-5.6-pro", name: "GPT-5.6 Pro", toolCalling: false }, // pro tier only, standard effort
{ id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking", toolCalling: false }, // plus, pro tier
{ id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended", toolCalling: false }, // pro tier only, extended effort
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro", toolCalling: false }, // pro tier only, standard effort
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking", toolCalling: false }, // plus, pro tier
{ id: "gpt-5.5", name: "GPT-5.5 Instant", toolCalling: false }, // free, plus, pro tier
{ id: "o3", name: "o3", toolCalling: false }, // plus ~ tier
],
};

View File

@@ -9,8 +9,8 @@ export const claude_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "claude-sonnet-5", name: "Claude 5 Sonnet (web)" },
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet (web)" },
{ id: "claude-haiku-4-5", name: "Claude 4.5 Haiku (web)" },
{ id: "claude-sonnet-5", name: "Claude 5 Sonnet (web)", toolCalling: false },
{ id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet (web)", toolCalling: false },
{ id: "claude-haiku-4-5", name: "Claude 4.5 Haiku (web)", toolCalling: false },
],
};

View File

@@ -8,5 +8,5 @@ export const copilot_m365_webProvider: RegistryEntry = {
baseUrl: "wss://substrate.office.com/m365Copilot/Chathub",
authType: "apikey",
authHeader: "cookie",
models: [{ id: "copilot-m365", name: "Microsoft 365 Copilot (BizChat)" }],
models: [{ id: "copilot-m365", name: "Microsoft 365 Copilot (BizChat)", toolCalling: false }],
};

View File

@@ -9,8 +9,8 @@ export const copilot_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "copilot-pro", name: "Copilot Pro (web)" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo (via Copilot)" },
{ id: "gpt-4", name: "GPT-4 (via Copilot)" },
{ id: "copilot-pro", name: "Copilot Pro (web)", toolCalling: false },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo (via Copilot)", toolCalling: false },
{ id: "gpt-4", name: "GPT-4 (via Copilot)", toolCalling: false },
],
};

View File

@@ -9,27 +9,27 @@ export const deepseek_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", toolCalling: false },
{ id: "deepseek-v4-pro-think", name: "DeepSeek V4 Pro Think", supportsReasoning: true },
{ id: "deepseek-v4-pro-search", name: "DeepSeek V4 Pro Search" },
{ id: "deepseek-v4-pro-search", name: "DeepSeek V4 Pro Search", toolCalling: false },
{
id: "deepseek-v4-pro-think-search",
name: "DeepSeek V4 Pro Think+Search",
supportsReasoning: true,
},
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", toolCalling: false },
{ id: "deepseek-v4-flash-think", name: "DeepSeek V4 Flash Think", supportsReasoning: true },
{ id: "deepseek-v4-flash-search", name: "DeepSeek V4 Flash Search" },
{ id: "deepseek-v4-flash-search", name: "DeepSeek V4 Flash Search", toolCalling: false },
{
id: "deepseek-v4-flash-think-search",
name: "DeepSeek V4 Flash Think+Search",
supportsReasoning: true,
},
{ id: "deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek-chat", name: "DeepSeek Chat", toolCalling: false },
{ id: "deepseek-reasoner", name: "DeepSeek Reasoner", supportsReasoning: true },
{ id: "DeepSeek-R1", name: "DeepSeek R1", supportsReasoning: true },
{ id: "DeepSeek-R1-Search", name: "DeepSeek R1 Search", supportsReasoning: true },
{ id: "DeepSeek-V3.2", name: "DeepSeek V3.2" },
{ id: "DeepSeek-Search", name: "DeepSeek Search" },
{ id: "DeepSeek-V3.2", name: "DeepSeek V3.2", toolCalling: false },
{ id: "DeepSeek-Search", name: "DeepSeek Search", toolCalling: false },
],
};

View File

@@ -9,7 +9,7 @@ export const doubao_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "dola-speed", name: "Dola Speed" },
{ id: "dola-pro", name: "Dola Pro" },
{ id: "dola-speed", name: "Dola Speed", toolCalling: false },
{ id: "dola-pro", name: "Dola Pro", toolCalling: false },
],
};

View File

@@ -9,11 +9,11 @@ export const duckduckgo_webProvider: RegistryEntry = {
authType: "none",
authHeader: "none",
models: [
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku" },
{ id: "llama-4-scout", name: "Llama 4 Scout" },
{ id: "mistral-small-2501", name: "Mistral Small" },
{ id: "o3-mini", name: "O3 Mini" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini", toolCalling: false },
{ id: "gpt-5-mini", name: "GPT-5 Mini", toolCalling: false },
{ id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", toolCalling: false },
{ id: "llama-4-scout", name: "Llama 4 Scout", toolCalling: false },
{ id: "mistral-small-2501", name: "Mistral Small", toolCalling: false },
{ id: "o3-mini", name: "O3 Mini", toolCalling: false },
],
};

View File

@@ -9,10 +9,10 @@ export const felo_webProvider: RegistryEntry = {
authType: "none",
authHeader: "none",
models: [
{ id: "felo-chat", name: "Felo Chat" },
{ id: "felo-search", name: "Felo Search" },
{ id: "felo-scholar", name: "Felo Scholar" },
{ id: "felo-social", name: "Felo Social" },
{ id: "felo-document", name: "Felo Document" },
{ id: "felo-chat", name: "Felo Chat", toolCalling: false },
{ id: "felo-search", name: "Felo Search", toolCalling: false },
{ id: "felo-scholar", name: "Felo Scholar", toolCalling: false },
{ id: "felo-social", name: "Felo Social", toolCalling: false },
{ id: "felo-document", name: "Felo Document", toolCalling: false },
],
};

View File

@@ -9,8 +9,8 @@ export const gemini_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" },
{ id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", toolCalling: false },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", toolCalling: false },
{ id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash-Lite", toolCalling: false },
],
};

View File

@@ -9,7 +9,7 @@ export const muse_spark_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "muse-spark", name: "Muse Spark" },
{ id: "muse-spark", name: "Muse Spark", toolCalling: false },
{
id: "muse-spark-thinking",
name: "Muse Spark Thinking",

View File

@@ -49,21 +49,21 @@ export const nvidiaProvider: RegistryEntry = {
{ id: "abacusai/dracarys-llama-3.1-70b-instruct", name: "Dracarys Llama 3.1 70B Instruct" },
{ id: "google/gemma-2-2b-it", name: "Gemma 2 2B IT" },
{ id: "google/gemma-3n-e2b-it", name: "Gemma 3n E2B IT" },
{ id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B Instruct" },
{ id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B Instruct", toolCalling: false },
{
id: "meta/llama-3.2-11b-vision-instruct",
name: "Llama 3.2 11B Vision Instruct",
supportsVision: true,
},
{ id: "meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" },
{ id: "meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct" },
{ id: "meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct", toolCalling: false },
{
id: "meta/llama-3.2-90b-vision-instruct",
name: "Llama 3.2 90B Vision Instruct",
supportsVision: true,
},
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick 17B 128E Instruct" },
{ id: "meta/llama-guard-4-12b", name: "Llama Guard 4 12B" },
{ id: "meta/llama-guard-4-12b", name: "Llama Guard 4 12B", toolCalling: false },
{ id: "mistralai/ministral-14b-instruct-2512", name: "Ministral 14B Instruct 2512" },
{ id: "mistralai/mistral-medium-3.5-128b", name: "Mistral Medium 3.5 128B" },
{ id: "mistralai/mistral-nemotron", name: "Mistral Nemotron" },

View File

@@ -9,16 +9,16 @@ export const perplexity_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "pplx-auto", name: "Perplexity Best" },
{ id: "pplx-sonar", name: "Sonar 2 (via Perplexity)" },
{ id: "pplx-gpt-5.6-terra", name: "GPT-5.6 Terra (via Perplexity)" },
{ id: "pplx-gpt-5.6-sol", name: "GPT-5.6 Sol (via Perplexity)" },
{ id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)" },
{ id: "pplx-sonnet", name: "Claude Sonnet 5.0 (via Perplexity)" },
{ id: "pplx-opus", name: "Claude Opus 4.8 (via Perplexity)" },
{ id: "pplx-glm", name: "GLM-5.2 (via Perplexity)" },
{ id: "pplx-kimi", name: "Kimi K2.6 (via Perplexity)" },
{ id: "pplx-grok-4.5", name: "Grok 4.5 (via Perplexity)" },
{ id: "pplx-nemotron", name: "Nemotron 3 Ultra (via Perplexity)" },
{ id: "pplx-auto", name: "Perplexity Best", toolCalling: false },
{ id: "pplx-sonar", name: "Sonar 2 (via Perplexity)", toolCalling: false },
{ id: "pplx-gpt-5.6-terra", name: "GPT-5.6 Terra (via Perplexity)", toolCalling: false },
{ id: "pplx-gpt-5.6-sol", name: "GPT-5.6 Sol (via Perplexity)", toolCalling: false },
{ id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)", toolCalling: false },
{ id: "pplx-sonnet", name: "Claude Sonnet 5.0 (via Perplexity)", toolCalling: false },
{ id: "pplx-opus", name: "Claude Opus 4.8 (via Perplexity)", toolCalling: false },
{ id: "pplx-glm", name: "GLM-5.2 (via Perplexity)", toolCalling: false },
{ id: "pplx-kimi", name: "Kimi K2.6 (via Perplexity)", toolCalling: false },
{ id: "pplx-grok-4.5", name: "Grok 4.5 (via Perplexity)", toolCalling: false },
{ id: "pplx-nemotron", name: "Nemotron 3 Ultra (via Perplexity)", toolCalling: false },
],
};

View File

@@ -10,36 +10,36 @@ export const t3_webProvider: RegistryEntry = {
authHeader: "cookie",
models: [
// Claude
{ id: "claude-opus-4", name: "Claude Opus 4 (via t3.chat)" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (via t3.chat)" },
{ id: "claude-haiku-4", name: "Claude Haiku 4 (via t3.chat)" },
{ id: "claude-3.7", name: "Claude 3.7 Sonnet (via t3.chat)" },
{ id: "claude-opus-4", name: "Claude Opus 4 (via t3.chat)", toolCalling: false },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (via t3.chat)", toolCalling: false },
{ id: "claude-haiku-4", name: "Claude Haiku 4 (via t3.chat)", toolCalling: false },
{ id: "claude-3.7", name: "Claude 3.7 Sonnet (via t3.chat)", toolCalling: false },
// GPT / OpenAI
{ id: "gpt-5", name: "GPT-5 (via t3.chat)" },
{ id: "gpt-4o", name: "GPT-4o (via t3.chat)" },
{ id: "gpt-4.1", name: "GPT-4.1 (via t3.chat)" },
{ id: "o3", name: "o3 (via t3.chat)" },
{ id: "o4-mini", name: "o4-mini (via t3.chat)" },
{ id: "gpt-5", name: "GPT-5 (via t3.chat)", toolCalling: false },
{ id: "gpt-4o", name: "GPT-4o (via t3.chat)", toolCalling: false },
{ id: "gpt-4.1", name: "GPT-4.1 (via t3.chat)", toolCalling: false },
{ id: "o3", name: "o3 (via t3.chat)", toolCalling: false },
{ id: "o4-mini", name: "o4-mini (via t3.chat)", toolCalling: false },
// Gemini
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (via t3.chat)" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (via t3.chat)" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (via t3.chat)", toolCalling: false },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (via t3.chat)", toolCalling: false },
// DeepSeek
{ id: "deepseek-r1", name: "DeepSeek R1 (via t3.chat)", supportsReasoning: true },
{ id: "deepseek-v3", name: "DeepSeek V3 (via t3.chat)" },
{ id: "deepseek-v3", name: "DeepSeek V3 (via t3.chat)", toolCalling: false },
// Grok
{ id: "grok-3", name: "Grok 3 (via t3.chat)" },
{ id: "grok-3-mini", name: "Grok 3 Mini (via t3.chat)" },
{ id: "grok-3", name: "Grok 3 (via t3.chat)", toolCalling: false },
{ id: "grok-3-mini", name: "Grok 3 Mini (via t3.chat)", toolCalling: false },
// Llama / Meta
{ id: "llama-4-maverick", name: "Llama 4 Maverick (via t3.chat)" },
{ id: "llama-4-scout", name: "Llama 4 Scout (via t3.chat)" },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B (via t3.chat)" },
{ id: "llama-4-maverick", name: "Llama 4 Maverick (via t3.chat)", toolCalling: false },
{ id: "llama-4-scout", name: "Llama 4 Scout (via t3.chat)", toolCalling: false },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B (via t3.chat)", toolCalling: false },
// Mistral
{ id: "devstral", name: "Devstral (via t3.chat)" },
{ id: "mistral-large", name: "Mistral Large (via t3.chat)" },
{ id: "devstral", name: "Devstral (via t3.chat)", toolCalling: false },
{ id: "mistral-large", name: "Mistral Large (via t3.chat)", toolCalling: false },
// Qwen
{ id: "qwen3-235b", name: "Qwen3 235B (via t3.chat)", supportsReasoning: true },
{ id: "qwen3-32b", name: "Qwen3 32B (via t3.chat)", supportsReasoning: true },
// Kimi
{ id: "kimi-k2", name: "Kimi K2 (via t3.chat)" },
{ id: "kimi-k2", name: "Kimi K2 (via t3.chat)", toolCalling: false },
],
};

View File

@@ -9,7 +9,7 @@ export const veoaifree_webProvider: RegistryEntry = {
authType: "none",
authHeader: "none",
models: [
{ id: "veo", name: "VEO 3.1" },
{ id: "seedance", name: "Seedance" },
{ id: "veo", name: "VEO 3.1", toolCalling: false },
{ id: "seedance", name: "Seedance", toolCalling: false },
],
};

View File

@@ -15,19 +15,19 @@ export const yuanbao_webProvider: RegistryEntry = {
name: "DeepSeek R1 (via Yuanbao)",
supportsReasoning: true,
},
{ id: "hunyuan", name: "Hunyuan (via Yuanbao)" },
{ id: "hunyuan", name: "Hunyuan (via Yuanbao)", toolCalling: false },
{
id: "hunyuan-t1",
name: "Hunyuan T1 (via Yuanbao)",
supportsReasoning: true,
},
{ id: "deepseek-v3-search", name: "DeepSeek V3 + Web Search (via Yuanbao)" },
{ id: "deepseek-v3-search", name: "DeepSeek V3 + Web Search (via Yuanbao)", toolCalling: false },
{
id: "deepseek-r1-search",
name: "DeepSeek R1 + Web Search (via Yuanbao)",
supportsReasoning: true,
},
{ id: "hunyuan-search", name: "Hunyuan + Web Search (via Yuanbao)" },
{ id: "hunyuan-search", name: "Hunyuan + Web Search (via Yuanbao)", toolCalling: false },
{
id: "hunyuan-t1-search",
name: "Hunyuan T1 + Web Search (via Yuanbao)",

View File

@@ -12,8 +12,8 @@ export const zai_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "glm-4.6", name: "GLM-4.6" },
{ id: "glm-4.5", name: "GLM-4.5" },
{ id: "glm-4.5v", name: "GLM-4.5V (Vision)" },
{ id: "glm-4.6", name: "GLM-4.6", toolCalling: false },
{ id: "glm-4.5", name: "GLM-4.5", toolCalling: false },
{ id: "glm-4.5v", name: "GLM-4.5V (Vision)", toolCalling: false },
],
};

View File

@@ -1,6 +1,11 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts";
function normalizeToolName(name: string): string {
return REVERSE_MAP[name] ?? name;
}
/**
* Direct Gemini → Claude response translator.
@@ -82,7 +87,7 @@ export function geminiToClaudeResponse(chunk, state) {
}
const fc = part.functionCall;
const rawToolName = fc.name;
const restoredToolName = state.toolNameMap?.get(rawToolName) || rawToolName;
const restoredToolName = normalizeToolName(state.toolNameMap?.get(rawToolName) || rawToolName);
const idx = state.contentBlockIndex++;
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;

View File

@@ -5,6 +5,83 @@ import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallS
import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts";
import { isAbortFinishReason } from "../../utils/finishReason.ts";
import { isInternalReasoningPlaceholder } from "../../utils/reasoningPlaceholder.ts";
import { REVERSE_MAP } from "../../services/claudeCodeToolRemapper.ts";
function normalizeToolName(name: string): string {
return REVERSE_MAP[name] ?? name;
}
interface XmlToolCall {
id: string;
name: string;
args: Record<string, string>;
}
/**
* Extract complete XML <invoke> blocks from text content.
* Some models (e.g. nvidia/abacusai/dracarys) emit tool calls as
* XML blocks instead of JSON tool_calls. This function detects
* <invoke name="ToolName"><parameter name="arg">value</parameter></invoke>
* blocks, converts them to tool calls, and returns the cleaned text.
* Incomplete XML is buffered in state for the next chunk.
*/
function extractXmlInvokeBlocks(
text: string,
state
): { cleaned: string; toolCalls: XmlToolCall[] } {
const toolCalls: XmlToolCall[] = [];
// Prepend any incomplete content from previous chunk
const combined = (state._xmlInvokeBuffer || "") + text;
state._xmlInvokeBuffer = "";
let remaining = combined;
let cleaned = "";
while (true) {
const startMatch = remaining.match(/<invoke\s+name="([^"]*)"\s*>/);
if (!startMatch) {
cleaned += remaining;
break;
}
// Text before the <invoke> block
cleaned += remaining.slice(0, startMatch.index);
const blockStart = startMatch.index;
const restAfterStart = remaining.slice(blockStart);
const endMatch = restAfterStart.match(/<\/invoke>/);
if (!endMatch) {
// Incomplete block — buffer for next chunk
state._xmlInvokeBuffer = restAfterStart;
break;
}
// Complete block found
const innerXml = restAfterStart.slice(startMatch[0].length, endMatch.index);
const fullBlock = restAfterStart.slice(0, endMatch.index + endMatch[0].length);
// Parse <parameter name="..." ...>value</parameter>
const args: Record<string, string> = {};
const paramRegex = /<parameter\s+name="([^"]*)"[^>]*>([\s\S]*?)<\/parameter>/g;
let pm;
while ((pm = paramRegex.exec(innerXml)) !== null) {
args[pm[1]] = pm[2].trim();
}
toolCalls.push({
id: `toolu_xml_${Date.now()}_${toolCalls.length}`,
name: startMatch[1],
args,
});
// Continue scanning after the block
remaining = remaining.slice(blockStart + fullBlock.length);
}
return { cleaned, toolCalls };
}
// Helper: stop thinking block if started
function stopThinkingBlock(state, results) {
@@ -81,6 +158,8 @@ export function openaiToClaudeResponse(chunk, state) {
}
state.model = chunk.model || "unknown";
state.nextBlockIndex = 0;
state._pendingXmlToolCalls = [];
state._xmlInvokeBuffer = "";
results.push({
type: "message_start",
message: {
@@ -133,22 +212,55 @@ export function openaiToClaudeResponse(chunk, state) {
if (delta?.content) {
stopThinkingBlock(state, results);
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
// Check for XML <invoke> blocks that some models emit instead of JSON tool_calls
const { cleaned, toolCalls: xmlToolCalls } = extractXmlInvokeBlocks(delta.content, state);
// Accumulate extracted tool calls for emission at finish
if (xmlToolCalls.length > 0) {
// Close any ongoing text block before tool calls
stopTextBlock(state, results);
state._pendingXmlToolCalls.push(...xmlToolCalls);
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: delta.content },
});
// Emit remaining non-XML text content
if (!cleaned) {
// All content was XML invoke blocks — skip text block entirely
// (tool calls will be emitted at finish)
} else if (xmlToolCalls.length > 0) {
// Text before/between/after XML blocks — (re)start a text block
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: cleaned },
});
} else {
// No XML — emit as regular text (original behaviour)
if (!state.textBlockStarted) {
state.textBlockIndex = state.nextBlockIndex++;
state.textBlockStarted = true;
state.textBlockClosed = false;
results.push({
type: "content_block_start",
index: state.textBlockIndex,
content_block: { type: "text", text: "" },
});
}
results.push({
type: "content_block_delta",
index: state.textBlockIndex,
delta: { type: "text_delta", text: cleaned },
});
}
}
// Tool calls
@@ -289,14 +401,38 @@ export function openaiToClaudeResponse(chunk, state) {
});
}
// Emit any XML-extracted tool calls (from models like Dracarys that
// emit <invoke> blocks in content instead of JSON tool_calls in delta)
const xmlToolCalls = state._pendingXmlToolCalls || [];
for (const tc of xmlToolCalls) {
const blockIndex = state.nextBlockIndex++;
results.push({
type: "content_block_start",
index: blockIndex,
content_block: {
type: "tool_use",
id: tc.id,
name: normalizeToolName(tc.name),
input: tc.args,
},
});
results.push({
type: "content_block_stop",
index: blockIndex,
});
}
// Override finish_reason to tool_use if XML tool calls were found
const overrideFinishReason = xmlToolCalls.length > 0 ? "tool_calls" : choice.finish_reason;
// Mark finish for later usage injection in stream.js
state.finishReason = choice.finish_reason;
state.finishReason = overrideFinishReason;
// Use tracked usage (will be estimated in stream.js if not valid)
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
results.push({
type: "message_delta",
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
delta: { stop_reason: convertFinishReason(overrideFinishReason) },
usage: finalUsage,
});
results.push({ type: "message_stop" });

108
package-lock.json generated
View File

@@ -346,9 +346,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -363,9 +360,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -380,9 +374,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -397,9 +388,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -5223,9 +5211,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5243,9 +5228,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5263,9 +5245,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5283,9 +5262,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5303,9 +5279,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5413,9 +5386,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5432,9 +5402,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5451,9 +5418,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5470,9 +5434,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -9587,9 +9548,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -10434,9 +10392,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -10453,9 +10408,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -10472,9 +10424,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -10491,9 +10440,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -10510,9 +10456,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -10529,9 +10472,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -31007,9 +30947,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31027,9 +30964,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31047,9 +30981,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31067,9 +30998,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31087,9 +31015,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31107,9 +31032,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31127,9 +31049,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31147,9 +31066,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -31167,9 +31083,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31193,9 +31106,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31219,9 +31129,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31245,9 +31152,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31271,9 +31175,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31297,9 +31198,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31323,9 +31221,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -31349,9 +31244,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [

View File

@@ -10,6 +10,8 @@ const { FORMATS } = await import("../../open-sse/translator/formats.ts");
function createState() {
return {
toolCalls: new Map(),
_pendingXmlToolCalls: [],
_xmlInvokeBuffer: "",
};
}
@@ -282,6 +284,190 @@ test("OpenAI non-stream: chat completion becomes Claude message with thinking an
});
});
test("OpenAI stream: XML <invoke> block in content becomes tool_use at finish", () => {
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-xml1",
model: "dracarys",
choices: [
{
index: 0,
delta: {
content:
'<invoke name="Bash"><parameter name="command" string="true">ls -la</parameter></invoke>',
},
finish_reason: null,
},
],
},
state
);
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-xml1",
model: "dracarys",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 },
},
state
);
const result = flatten([chunk1, chunk2]);
// message_start → (no text block since all content was XML)
assert.equal(result[0].type, "message_start");
// At finish: tool_use content_block_start
const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use");
assert.ok(toolStart, "expected tool_use content_block_start");
assert.equal(toolStart.content_block.name, "bash"); // normalized via REVERSE_MAP
assert.deepEqual(toolStart.content_block.input, { command: "ls -la" });
// tool_use content_block_stop
const toolStop = result.find((e) => e.type === "content_block_stop");
assert.ok(toolStop, "expected content_block_stop after tool_use");
// message_delta with tool_use stop_reason
const delta = result.find((e) => e.type === "message_delta");
assert.ok(delta, "expected message_delta");
assert.equal(delta.delta.stop_reason, "tool_use");
});
test("OpenAI stream: XML invoke block across two streaming chunks", () => {
const state = createState();
// Chunk 1: partial XML
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-xml2",
model: "dracarys",
choices: [
{
index: 0,
delta: { content: '<invoke name="Read"><parameter name="file_path" string="true">/etc/' },
finish_reason: null,
},
],
},
state
);
// Chunk 2: completes the XML
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-xml2",
model: "dracarys",
choices: [
{
index: 0,
delta: { content: 'hosts</parameter></invoke>' },
finish_reason: null,
},
],
},
state
);
// Chunk 3: finish
const chunk3 = openaiToClaudeResponse(
{
id: "chatcmpl-xml2",
model: "dracarys",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 },
},
state
);
const result = flatten([chunk1, chunk2, chunk3]);
// Buffer should be cleared after chunk2
assert.equal(state._xmlInvokeBuffer, "", "buffer cleared after complete block");
const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use");
assert.ok(toolStart, "expected tool_use content_block_start");
assert.equal(toolStart.content_block.name, "read");
assert.deepEqual(toolStart.content_block.input, { file_path: "/etc/hosts" });
});
test("OpenAI stream: text before XML block is emitted as text content", () => {
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-xml3",
model: "dracarys",
choices: [
{
index: 0,
delta: {
content:
'Checking...<invoke name="Bash"><parameter name="command">date</parameter></invoke>',
},
finish_reason: null,
},
],
},
state
);
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-xml3",
model: "dracarys",
choices: [
{
index: 0,
delta: { content: " Done." },
finish_reason: null,
},
],
},
state
);
const chunk3 = openaiToClaudeResponse(
{
id: "chatcmpl-xml3",
model: "dracarys",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 8, total_tokens: 11 },
},
state
);
const result = flatten([chunk1, chunk2, chunk3]);
// "Checking..." should be emitted as text
const textDeltas = result.filter((e) => e.type === "content_block_delta" && e.delta?.type === "text_delta");
assert.ok(textDeltas.length > 0, "expected at least one text delta");
assert.ok(textDeltas.some((d) => d.delta.text.includes("Checking...")), "text before XML preserved");
assert.ok(textDeltas.some((d) => d.delta.text.includes("Done.")), "text after XML preserved");
// Tool call should still be emitted
const toolStart = result.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use");
assert.ok(toolStart, "expected tool_use content_block_start");
assert.equal(toolStart.content_block.name, "bash");
assert.deepEqual(toolStart.content_block.input, { command: "date" });
});
test("OpenAI stream: no XML in content behaves normally", () => {
const state = createState();
const chunk1 = openaiToClaudeResponse(
{
id: "chatcmpl-normal",
model: "gpt-4.1",
choices: [{ index: 0, delta: { content: "Hello world" }, finish_reason: null }],
},
state
);
const chunk2 = openaiToClaudeResponse(
{
id: "chatcmpl-normal",
model: "gpt-4.1",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
},
state
);
const result = flatten([chunk1, chunk2]);
assert.equal(result[1].type, "content_block_start");
assert.equal(result[2].delta.text, "Hello world");
assert.equal(result[3].type, "content_block_stop");
assert.equal(result[4].type, "message_delta");
assert.equal(result[4].delta.stop_reason, "end_turn");
});
test("OpenAI stream: null chunk is ignored", () => {
assert.equal(openaiToClaudeResponse(null, createState()), null);
});