feat(cross-cutting): added multi-syntax in-band tool-call support for runtime tool conversion

- Added optional Agent and SDK tool-call syntax controls (`toolCallSyntax`, `PI_OWNED_TOOLS`) for owned calls.
- Added in-band grammar scanners and renderers for Anthropic, DeepSeek, GLM, Hermes, Kimi, PI, and Qwen3.
- Added supportsTools propagation and model schema updates to route unsupported models to fallback syntax.
- Replaced stream-markup parsing with syntax-specific in-band scanners and event conversion.
This commit is contained in:
can1357
2026-06-15 06:44:54 +02:00
parent 907bc9979e
commit fbba331f8a
66 changed files with 7528 additions and 543 deletions
@@ -130,11 +130,13 @@ export function mergeDiscoveredModel<TApi extends Api>(
providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
): Model<TApi> {
if (existing) {
const supportsTools = model.supportsTools ?? existing.supportsTools;
return buildModel({
...model,
baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
transport: providerOverride?.transport ?? existing.transport ?? model.transport,
...(supportsTools !== undefined ? { supportsTools } : {}),
compat: model.compatConfig,
} as ModelSpec<TApi>);
}
@@ -370,6 +372,7 @@ interface ModelPatch {
reasoning?: boolean;
thinking?: ThinkingConfig;
input?: ("text" | "image")[];
supportsTools?: boolean;
cost?: Partial<Model<Api>["cost"]>;
contextWindow?: number;
maxTokens?: number;
@@ -395,6 +398,7 @@ function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTr
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
if (patch.thinking !== undefined) result.thinking = patch.thinking;
if (patch.input !== undefined) result.input = patch.input;
if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools;
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
@@ -506,6 +510,7 @@ function buildCustomModelOverlay(
reasoning: modelDef.reasoning,
thinking: modelDef.thinking,
input: modelDef.input,
supportsTools: modelDef.supportsTools,
cost: modelDef.cost,
contextWindow: modelDef.contextWindow,
maxTokens: modelDef.maxTokens,
@@ -535,6 +540,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
reference?.cost ??
(options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
const supportsTools = resolvedModel.supportsTools ?? reference?.supportsTools;
return buildModel({
id: resolvedModel.id,
name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
@@ -544,6 +550,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
reasoning: resolvedModel.reasoning ?? reference?.reasoning ?? (options.useDefaults ? false : undefined),
thinking: resolvedModel.thinking ?? reference?.thinking,
input: input as ("text" | "image")[],
...(supportsTools !== undefined ? { supportsTools } : {}),
cost,
contextWindow: resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : null),
maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : null),
@@ -878,10 +885,12 @@ export class ModelRegistry {
#mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => {
if (!existing) return replacementModel;
const supportsTools = replacementModel.supportsTools ?? existing.supportsTools;
return {
...replacementModel,
contextWindow: replacementModel.contextWindow ?? existing.contextWindow,
maxTokens: replacementModel.maxTokens ?? existing.maxTokens,
...(supportsTools !== undefined ? { supportsTools } : {}),
};
});
}
@@ -2205,6 +2214,7 @@ export interface ProviderConfigInput {
reasoning: boolean;
thinking?: ThinkingConfig;
input: ("text" | "image")[];
supportsTools?: boolean;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
@@ -133,6 +133,7 @@ const ModelDefinitionSchema = z.object({
reasoning: z.boolean().optional(),
thinking: ModelThinkingSchema.optional(),
input: z.array(z.enum(["text", "image"])).optional(),
supportsTools: z.boolean().optional(),
cost: z
.object({
input: z.number(),
@@ -155,6 +156,7 @@ export const ModelOverrideSchema = z.object({
reasoning: z.boolean().optional(),
thinking: ModelThinkingSchema.optional(),
input: z.array(z.enum(["text", "image"])).optional(),
supportsTools: z.boolean().optional(),
cost: z
.object({
input: z.number().optional(),
@@ -17,6 +17,7 @@ export interface ProviderValidationModel {
id: string;
api?: Api;
contextWindow?: number;
supportsTools?: boolean;
maxTokens?: number;
}
@@ -1719,6 +1719,48 @@ export const SETTINGS_SCHEMA = {
},
},
"tools.format": {
type: "enum",
values: [
"auto",
"native",
"glm",
"hermes",
"kimi",
"xml",
"anthropic",
"deepseek",
"harmony",
"pi",
"qwen3",
] as const,
default: "auto",
ui: {
tab: "context",
group: "Experimental",
label: "Tool Call Format",
description:
"Controls how tools are exposed to the model. Auto uses native tool calls unless the selected model is marked as not supporting tools, then falls back to GLM-style in-band tool calls. Native forces provider-native tools; the other values force the named in-band syntax. Applies on session start.",
options: [
{
value: "auto",
label: "Auto",
description: "Use native tool calls unless the model is known not to support them.",
},
{ value: "native", label: "Native", description: "Use provider-native tool calls." },
{ value: "glm", label: "GLM", description: "Use GLM-style in-band tool calls." },
{ value: "hermes", label: "Hermes", description: "Use Hermes-style in-band tool calls." },
{ value: "kimi", label: "Kimi", description: "Use Kimi-style in-band tool calls." },
{ value: "xml", label: "XML", description: "Use generic XML in-band tool calls." },
{ value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
{ value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
{ value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
{ value: "pi", label: "Pi", description: "Use Pi-style in-band tool calls." },
{ value: "qwen3", label: "Qwen3", description: "Use Qwen3-style in-band tool calls." },
],
},
},
"snapcompact.shape": {
type: "enum",
values: ["auto", ...SHAPE_VARIANT_NAMES] as const,
+13
View File
@@ -16,6 +16,7 @@ import {
type SimpleStreamOptions,
streamSimple,
} from "@oh-my-pi/pi-ai";
import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
import {
getOpenAICodexTransportDetails,
prewarmOpenAICodexResponses,
@@ -550,6 +551,17 @@ export interface CreateAgentSessionResult {
eventBus: EventBus;
}
export type ToolCallFormat = "auto" | "native" | ToolCallSyntax;
export function resolveToolCallSyntax(
format: ToolCallFormat,
model: Pick<Model, "supportsTools"> | undefined,
): ToolCallSyntax | undefined {
if (format === "native") return undefined;
if (format === "auto") return model?.supportsTools === false ? "glm" : undefined;
return format;
}
// Re-exports
export type { PromptTemplate } from "./config/prompt-templates";
@@ -2489,6 +2501,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return result;
},
intentTracing: !!intentField,
toolCallSyntax: resolveToolCallSyntax(settings.get("tools.format"), model),
getToolChoice: () => session?.nextToolChoice(),
telemetry: options.telemetry,
appendOnlyContext: model