diff --git a/src/plugin-sdk/provider-tools.test.ts b/src/plugin-sdk/provider-tools.test.ts index 0297446a960..d39774706e7 100644 --- a/src/plugin-sdk/provider-tools.test.ts +++ b/src/plugin-sdk/provider-tools.test.ts @@ -119,6 +119,90 @@ describe("buildProviderToolCompatFamilyHooks", () => { ).toStrictEqual([]); }); + it("preserves string-const unions as a flat enum for the deepseek family", () => { + // Regression for https://github.com/openclaw/openclaw/issues/86468 — + // Typebox `Type.Union([Type.Literal(...)])` collapses to anyOf of consts; + // the previous normalizer kept only the first const, hiding every other + // literal from the model. + const hooks = buildProviderToolCompatFamilyHooks("deepseek"); + const tools = [ + { + name: "feishu_update_doc", + description: "", + parameters: { + type: "object", + properties: { + mode: { + description: "更新模式(必填)", + anyOf: [ + { const: "overwrite", type: "string" }, + { const: "append", type: "string" }, + { const: "replace_range", type: "string" }, + ], + }, + optional_mode: { + anyOf: [ + { const: "a", type: "string" }, + { const: "b", type: "string" }, + { type: "null" }, + ], + }, + single_const: { + anyOf: [{ const: "only", type: "string" }], + }, + }, + required: ["mode"], + }, + }, + ] as never; + + const normalized = hooks.normalizeToolSchemas({ + provider: "deepseek", + modelId: "deepseek-v4-pro", + modelApi: "openai-completions", + model: { + provider: "deepseek", + api: "openai-completions", + id: "deepseek-v4-pro", + } as never, + tools, + }); + + expect(normalized[0]?.parameters).toEqual({ + type: "object", + properties: { + mode: { + description: "更新模式(必填)", + type: "string", + enum: ["overwrite", "append", "replace_range"], + }, + optional_mode: { + type: "string", + enum: ["a", "b"], + nullable: true, + }, + single_const: { + const: "only", + type: "string", + }, + }, + required: ["mode"], + }); + expect( + hooks.inspectToolSchemas({ + provider: "deepseek", + modelId: "deepseek-v4-pro", + modelApi: "openai-completions", + model: { + provider: "deepseek", + api: "openai-completions", + id: "deepseek-v4-pro", + } as never, + tools: normalized, + }), + ).toStrictEqual([]); + }); + it("normalizes parameter-free and typed-object schemas for the openai family", () => { const hooks = buildProviderToolCompatFamilyHooks("openai"); const tools = [ diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index fdf988a080b..c999786759c 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -455,6 +455,25 @@ function normalizeDeepSeekSchema(schema: unknown): unknown { const variants = record[unionKey] as unknown[]; const normalizedVariants = variants.map((entry) => normalizeDeepSeekSchema(entry)); const nonNullVariants = normalizedVariants.filter((entry) => !isNullSchemaVariant(entry)); + const hasNullVariant = nonNullVariants.length < normalizedVariants.length; + + // Preserve string-const unions as a flat string enum so DeepSeek tool + // callers still see every allowed literal. Without this, a Typebox + // `Type.Union([Type.Literal("a"), Type.Literal("b"), ...])` collapses to + // only the first const and the model can never pick any other value. + if (nonNullVariants.length > 1 && nonNullVariants.every((entry) => isStringConstVariant(entry))) { + const enumValues = nonNullVariants.map((entry) => (entry as { const: string }).const); + const merged: Record = { + ...normalized, + type: "string", + enum: enumValues, + }; + if (hasNullVariant) { + merged.nullable = true; + } + return merged; + } + const selected = nonNullVariants[0] ?? normalizedVariants[0]; if (!selected || typeof selected !== "object" || Array.isArray(selected)) { return normalized; @@ -464,12 +483,20 @@ function normalizeDeepSeekSchema(schema: unknown): unknown { ...(selected as Record), ...normalized, }; - if (nonNullVariants.length < normalizedVariants.length) { + if (hasNullVariant) { merged.nullable = true; } return merged; } +function isStringConstVariant(entry: unknown): entry is { const: string } { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + const record = entry as Record; + return typeof record.const === "string"; +} + export function normalizeDeepSeekToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] {