Skip to content

fix(core): stringify const-derived enums in toOpenAPI30 - #7547

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
chinesepowered:fix/const-enum-stringify
Jul 25, 2026
Merged

fix(core): stringify const-derived enums in toOpenAPI30#7547
wenshao merged 2 commits into
QwenLM:mainfrom
chinesepowered:fix/const-enum-stringify

Conversation

@chinesepowered

Copy link
Copy Markdown
Contributor

What this PR does

toOpenAPI30 builds its const-derived enum with String(), so it obeys the same string-enum rule the converter already enforces for enum. One-line change plus tests.

Why it's needed

The converter does two related things in separate steps:

// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
if (source['const'] !== undefined) {
  target['enum'] = [source['const']];
  delete target['const'];
}

// 5. Enum Stringification
// Gemini strictly requires enums to be strings
if (Array.isArray(source['enum'])) {
  target['enum'] = source['enum'].map(String);
}

Step 5 keys off source['enum'] — the source schema — which a const-only schema never sets. So the enum step 2 just created is never stringified, and the converter violates its own documented invariant. Verified via convertSchema(schema, 'openapi_30') on main:

input output on main
{ const: 5 } { enum: [5] } number — breaks the rule
{ const: true } { enum: [true] } boolean — likewise
{ const: 'x' } { enum: ['x'] } fine, but only because it was already a string
{ enum: [1, 2] } { enum: ['1', '2'] } the intended behavior

So two schemas that mean the same thing — { const: 5 } and { enum: [5] } — convert to different value types, and the one that goes through const is the one the comment says Gemini will reject.

Reviewer Test Plan

How to verify

  • From the repo root: npx vitest run --root packages/core src/utils/schemaConverter.test.ts → 23/23.
  • The new test should stringify a non-string const like any other enum covers { const: 5 }, { const: true }, and { type: 'integer', const: 0 } (the last one also pins that type is left alone). Reverting only schemaConverter.ts fails it with expected { enum: [ 5 ] } to deeply equal { enum: [ '5' ] }.
  • The existing should convert const to enum ({ const: 'foo' }{ enum: ['foo'] }) and should stringify enums tests are untouched and still pass.
  • The only consumer, anthropicContentGenerator/converter.ts, is unaffected: schemaConverter + anthropic converter suites are 90/90 together.

Evidence (Before & After)

  • Before: convertSchema({ const: 5 }, 'openapi_30'){ enum: [5] }.
  • After: { enum: ['5'] }, matching convertSchema({ enum: [5] }, 'openapi_30'){ enum: ['5'] }.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS: schemaConverter (23) and the anthropicContentGenerator converter suite pass locally with proven fail-before/pass-after; typecheck and eslint clean. Pure value transformation with no platform-dependent behavior, so no manual QA is required; CI covers Windows/Linux.

Environment (optional)

Node v24; @qwen-code/qwen-code-core workspace; vitest 3.2.

Risk & Scope

  • Main risk or tradeoff: a numeric or boolean const now reaches the model as a string. That is the point — the module states Gemini requires string enums and already does exactly this to every enum array, so this makes the two paths agree rather than introducing a new policy.
  • Not validated / out of scope: type is deliberately left untouched, so { type: 'integer', const: 0 } becomes { type: 'integer', enum: ['0'] }. That mirrors what the existing enum stringification already produces for { type: 'integer', enum: [0] } — whether the converter should also coerce type to 'string' alongside a stringified enum is a pre-existing question that applies equally to both paths, so it did not belong in this change.
  • Breaking changes / migration notes: none for string consts, which are unchanged.

Linked Issues

None — found by reading step 2 against step 5 in toOpenAPI30.

中文说明

本 PR 的作用

toOpenAPI30 在构造由 const 派生的 enum 时改用 String(),使其遵守该转换器已对 enum 强制执行的同一条「字符串 enum」规则。一行改动,外加测试。

为什么需要

转换器在两个独立步骤中做了两件相关的事:

// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
if (source['const'] !== undefined) {
  target['enum'] = [source['const']];
  delete target['const'];
}

// 5. Enum Stringification
// Gemini strictly requires enums to be strings
if (Array.isArray(source['enum'])) {
  target['enum'] = source['enum'].map(String);
}

第 5 步依据的是 source['enum']—— schema——而只含 const 的 schema 从不设置它。于是第 2 步刚刚创建的 enum 永远不会被字符串化,转换器违反了自己文档化的不变式。在 main 上通过 convertSchema(schema, 'openapi_30') 验证:

输入 main 上的输出
{ const: 5 } { enum: [5] } 数字——违反规则
{ const: true } { enum: [true] } 布尔——同样违反
{ const: 'x' } { enum: ['x'] } 正常,但只是因为它本就是字符串
{ enum: [1, 2] } { enum: ['1', '2'] } 预期行为

于是语义相同的两个 schema——{ const: 5 }{ enum: [5] }——会转换成不同的值类型,而走 const 的那条恰恰是注释所说 Gemini 会拒绝的形式。

复核测试计划

如何验证

  • 在仓库根目录:npx vitest run --root packages/core src/utils/schemaConverter.test.ts → 23/23 通过。
  • 新增测试 should stringify a non-string const like any other enum 覆盖 { const: 5 }{ const: true }{ type: 'integer', const: 0 }(最后一项同时固定了 type 不被改动)。仅还原 schemaConverter.ts 时该测试失败:expected { enum: [ 5 ] } to deeply equal { enum: [ '5' ] }
  • 既有的 should convert const to enum{ const: 'foo' }{ enum: ['foo'] })与 should stringify enums 未作改动且仍然通过。
  • 唯一的消费方 anthropicContentGenerator/converter.ts 不受影响:schemaConverter 与 anthropic converter 两个套件合计 90/90 通过。

证据(修复前后对比)

  • 修复前:convertSchema({ const: 5 }, 'openapi_30'){ enum: [5] }
  • 修复后:{ enum: ['5'] },与 convertSchema({ enum: [5] }, 'openapi_30'){ enum: ['5'] } 一致。

测试环境

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS:schemaConverter(23)与 anthropicContentGenerator converter 套件本地通过,并验证了 fail-before/pass-after;typecheck 与 eslint 干净。纯值变换,无平台相关行为,因此无需人工 QA;Windows/Linux 由 CI 覆盖。

运行环境(可选)

Node v24;@qwen-code/qwen-code-core 工作区;vitest 3.2。

风险与影响范围

  • 主要风险或权衡:数字或布尔型 const 现在会以字符串形式送达模型。这正是本意——该模块明确声明 Gemini 要求字符串 enum,并且已对每个 enum 数组做了完全相同的处理;本改动只是让两条路径保持一致,而非引入新策略。
  • 未验证 / 范围之外:有意不改动 type,因此 { type: 'integer', const: 0 } 变为 { type: 'integer', enum: ['0'] }。这与既有的 enum 字符串化对 { type: 'integer', enum: [0] } 的产出完全一致——转换器是否应在字符串化 enum 的同时把 type 也改为 'string',是一个对两条路径同样适用的既有问题,不属于本次改动。
  • 破坏性变更 / 迁移说明:对字符串型 const 无影响,其行为不变。

关联 Issue

无——通过将 toOpenAPI30 的第 2 步与第 5 步对照阅读发现。

toOpenAPI30 maps const to a single-value enum, then separately stringifies
enums because — per its own comment — Gemini strictly requires enums to be
strings. The two never met: the stringification keys off source['enum'],
which a const-only schema never sets, so the const value went through raw.

  { const: 5 }     -> { enum: [5] }        // number, breaks the rule
  { const: true }  -> { enum: [true] }     // boolean, likewise
  { enum: [1, 2] } -> { enum: ['1', '2'] } // the intended behavior

Build the const-derived enum with String() so both paths produce the same
kind of value.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections are present, including a filled-in Tested-on table and a full Chinese translation.

Problem: this is an observed, demonstrable inconsistency, not theoretical hardening. The converter documents "Gemini strictly requires enums to be strings" and stringifies every enum array (step 5), but step 5 keys off source['enum'], which a const-only schema never sets — so the enum that step 2 builds from const skips stringification entirely. I reproduced it on main: convertSchema({ const: 5 }, 'openapi_30') returns { enum: [5] } (a number), while the equivalent { enum: [5] } returns { enum: ['5'] }. Two schemas that mean the same thing convert to different value types, and the const path is the one the comment says Gemini rejects.

Direction: aligned. Making the const-derived path obey the same string-enum rule the module already enforces is squarely within this converter's stated purpose. No auth/sandbox/model-selection/telemetry/public-contract surface involved.

Size: core path touched (packages/core/src/utils/schemaConverter.ts), but only 6 production logic lines (5+ / 1-); the other 15 added lines are the collocated test. Well under any threshold — no maintainer-awareness flag needed.

Approach: about as minimal as it gets — a one-line String() wrap, a comment explaining why step 5 can't cover it, and one focused test. I also think you scoped it correctly by leaving the type-coercion question out: it applies equally to the existing enum path, so it's a pre-existing question, not something this change introduces. Nothing to cut.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小节齐全,包括填写好的 Tested-on 表格和完整中文翻译。

问题:这是一个已观测到、可复现的不一致,而非理论性加固。转换器明确声明「Gemini 严格要求 enum 为字符串」,并对每个 enum 数组做字符串化(第 5 步);但第 5 步依据的是 source['enum'],而只含 const 的 schema 从不设置它——于是第 2 步由 const 构造出的 enum 完全跳过了字符串化。我在 main 上复现:convertSchema({ const: 5 }, 'openapi_30') 返回 { enum: [5] }(数字),而等价的 { enum: [5] } 返回 { enum: ['5'] }。两个语义相同的 schema 转换出了不同的值类型,而走 const 的那条恰恰是注释所说 Gemini 会拒绝的形式。

方向:对齐。让 const 派生路径遵守该模块已强制执行的同一条字符串 enum 规则,完全在此转换器的既定职责之内。不涉及 auth/sandbox/模型选择/telemetry/公共契约。

规模:触及核心路径(packages/core/src/utils/schemaConverter.ts),但仅 6 行生产逻辑(5+ / 1-);另外 15 行新增是同位的测试。远低于任何阈值——无需提请维护者关注。

方案:已经尽可能精简——一行 String() 包裹、一条解释「为何第 5 步无法覆盖」的注释,以及一个聚焦的测试。我也认为把 type 强转问题排除在外是正确的:它对既有 enum 路径同样适用,属于既有问题,而非本改动引入。无可删减。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at a9fbb4673242f4c6e0beae41ed711fcb16af7e90 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Reading just the title + "Why it's needed", my independent fix would have been one of two things: wrap the const value in String() at step 2, or make step 5 key off target['enum'] instead of source['enum'] so it catches the const-derived enum too. The PR took the first option, and it's the better one — re-pointing step 5 at target would have a wider blast radius (it would run on every enum step 2 produced, plus anything else that lands in target['enum']), whereas the String() wrap at step 2 is local and mirrors exactly what step 5 already does. So the approach matches the simplest path I'd have chosen.

Correctness checks out. I traced toOpenAPI30 end to end: step 2 now emits [String(source['const'])], step 5 still keys off source['enum'] (unchanged), and the two never collide for a const-only schema. The only degenerate case — a const that is itself an object — would stringify to '[object Object]', but that is byte-for-byte what step 5 already does for an object inside an enum array, so it's consistent rather than a new regression, and a meaningless input either way. The added comment earns its place: it explains the non-obvious why (step 5 can't cover this because it keys off the source, which a const-only schema never populates). Conventions are clean — collocated test, kebab-case file, ESM, no any.

One tiny note for the maintainer, not a blocker: the PR description calls anthropicContentGenerator/converter.ts "the only consumer", but openaiContentGenerator/converter.ts:367 also calls convertSchema. Both go through the same function and both are unaffected in the default auto mode; I ran both suites to confirm (below). Worth a one-word fix in the description but it changes nothing about the code.

Real-scenario testing

tmux is not installed on this runner (and there's no passwordless sudo to add it), so instead of a TUI session I drove the actual converter source directly — running the real convertSchema from each tree under tsx, then the real vitest suites. Output below is the genuine terminal capture.

Before (main d064bd7) vs After (this PR a9fbb46)

$ npx tsx /tmp/repro-const.mts <main>/packages/core/src/utils/schemaConverter.ts
{ const: 5 }           -> {"enum":[5]}
{ const: true }        -> {"enum":[true]}
{ type:'integer', const: 0 } -> {"type":"integer","enum":[0]}
{ const: 'x' }         -> {"enum":["x"]}
{ enum: [1, 2] }       -> {"enum":["1","2"]}

$ npx tsx /tmp/repro-const.mts <worktree>/packages/core/src/utils/schemaConverter.ts
{ const: 5 }           -> {"enum":["5"]}
{ const: true }        -> {"enum":["true"]}
{ type:'integer', const: 0 } -> {"type":"integer","enum":["0"]}
{ const: 'x' }         -> {"enum":["x"]}
{ enum: [1, 2] }       -> {"enum":["1","2"]}

The const path now agrees with the enum path; string consts and the existing enum stringification are unchanged.

Unit tests (PR code)

$ npx vitest run src/utils/schemaConverter.test.ts
 ✓ src/utils/schemaConverter.test.ts (23 tests) 5ms
 Test Files  1 passed (1)
      Tests  23 passed (23)

Fail-before / pass-after confirmed: reverting only the one source line makes the new test fail with exactly the error the PR cites, while the pre-existing should convert const to enum and should stringify enums stay green.

$ npx vitest run src/utils/schemaConverter.test.ts   # fix reverted
 × convertSchema > mode: openapi_30 (strict) > should stringify a non-string const like any other enum
   → expected { enum: [ 5 ] } to deeply equal { enum: [ '5' ] }
 Tests  22 passed | 1 failed (23)

Consumer suites (no regression)

$ npx vitest run src/core/anthropicContentGenerator/converter.test.ts
 Test Files  1 passed (1)
      Tests  67 passed (67)

$ npx vitest run src/core/openaiContentGenerator/
 Test Files  17 passed (17)
      Tests  673 passed (673)

schemaConverter (23) + anthropic converter (67) = 90/90, matching the PR's claim; the openai consumer the PR didn't mention is green too. ESLint on both changed files is clean.

中文说明

代码审查

只看标题和「为什么需要」,我独立想到的修复有两条:在第 2 步把 const 值用 String() 包裹,或者让第 5 步改为依据 target['enum'] 而非 source['enum'],从而也能覆盖 const 派生的 enum。PR 选了第一条,而且更优——把第 5 步改指向 target 会有更大的影响面(会对第 2 步产生的每个 enum、以及任何落入 target['enum'] 的东西都执行),而第 2 步的 String() 包裹是局部的,且与第 5 步既有做法完全一致。所以该方案与我会选的最简路径一致。

正确性经核查无误。我通读了 toOpenAPI30:第 2 步现在输出 [String(source['const'])],第 5 步仍依据 source['enum'](未改动),两者对只含 const 的 schema 永不冲突。唯一的退化情形——const 本身是对象——会被字符串化为 '[object Object]',但这与第 5 步对 enum 数组中对象的处理逐字节一致,因此是一致而非新的回归,且该输入本就无意义。新增注释恰到好处:解释了非显而易见的「为什么」(第 5 步无法覆盖,因为它依据的是 source,而只含 const 的 schema 从不填充它)。约定干净——同位测试、kebab-case 文件名、ESM、无 any

给维护者的一点小提示,非阻塞:PR 描述把 anthropicContentGenerator/converter.ts 称为「唯一消费方」,但 openaiContentGenerator/converter.ts:367 同样调用了 convertSchema。两者都经由同一函数,且在默认 auto 模式下都不受影响;我已分别跑了两套测试(见下)。描述里改一个词即可,对代码本身毫无影响。

真实场景测试

本 runner 未安装 tmux(且无免密 sudo 可安装),因此我没有跑 TUI 会话,而是直接驱动真实的转换器源码——在 tsx 下分别运行两棵树里真实的 convertSchema,再跑真实的 vitest 套件。以下为真实终端输出。

const 路径现在与 enum 路径一致;字符串 const 与既有 enum 字符串化行为不变。单测 23/23 通过;仅还原一行源码即按 PR 所述报错(fail-before/pass-after 得证)。消费方套件无回归:anthropic converter 67/67,openai 17 文件 673/673。schemaConverter(23) + anthropic converter(67) = 90/90,与 PR 声明一致;PR 未提及的 openai 消费方同样全绿。两个改动文件的 ESLint 干净。

Qwen Code · qwen3.8-max-preview

Reviewed at a9fbb4673242f4c6e0beae41ed711fcb16af7e90 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; I'd merge this without hesitation.

Stepping back: this is exactly the kind of PR the gate should wave through quickly. The problem is real and I verified it independently on main — not accepted on the PR's framing. The fix is the simplest correct one (I'd have chosen the same String() wrap over re-pointing step 5), it's one line plus a comment that explains a genuinely non-obvious why, and the test is focused and proves fail-before/pass-after. After running it, the output matches the promise precisely, and neither consumer regresses.

The only reservation, and it's cosmetic, is the description calling the anthropic converter "the only consumer" when the openai converter also calls convertSchema — I confirmed both are green, so it changes nothing about the code. If I had to maintain this in six months I'd thank the author: a tight, well-justified consistency fix with a clear comment and a real test.

The fork-refactor approval guardrail does not apply (this is a fix, not a refactor), and nothing was escalated at Stage 0. Approving, pinned to the reviewed commit.

中文说明

置信度:5/5 —— 每个阶段都干净;我会毫不犹豫地合并。

退一步看:这正是 gate 应当快速放行的那类 PR。问题真实存在,且我已在 main 上独立核实——并非照单接受 PR 的说法。修复是最简的正确方案(相比把第 5 步改指向,我也会选同样的 String() 包裹),仅一行加一条解释了真正非显而易见之「为什么」的注释,测试聚焦并证明了 fail-before/pass-after。运行之后,输出与承诺完全一致,两个消费方均无回归。

唯一的保留意见,且属措辞层面:描述把 anthropic converter 称为「唯一消费方」,而 openai converter 同样调用了 convertSchema——我已确认两者全绿,因此对代码毫无影响。若半年后由我维护,我会感谢作者:这是一处紧凑、论证充分的一致性修复,附带清晰的注释和真实的测试。

fork-refactor 批准护栏不适用(这是 fix 而非 refactor),Stage 0 也未有任何升级。批准,并锁定到所审查的提交。

Qwen Code · qwen3.8-max-preview

Reviewed at a9fbb4673242f4c6e0beae41ed711fcb16af7e90 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@gwinthis

Copy link
Copy Markdown
Collaborator

Review & Local Verification Report

代码审查

设计评价:一致性修复。 本 PR 修复了 toOpenAPI30const 值未字符串化的问题。

问题: enum: [1, 2] 会被步骤 5 字符串化为 ['1', '2'](Gemini 要求 string enum),但 const: 5 转换为 enum: [5] 时跳过了字符串化——因为步骤 5 只检查 source['enum'],而 const-only schema 没有这个键。

修复: target['enum'] = [String(source['const'])]——在 const → enum 转换时直接字符串化。

测试覆盖: const: 5enum: ['5']const: trueenum: ['true']const: 0enum: ['0']

结论

LGTM。 单行修复,与现有 enum 字符串化行为保持一致。注释解释了为何步骤 5 无法覆盖此场景。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@ZijianZhang989 ZijianZhang989 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 25, 2026
Merged via the queue into QwenLM:main with commit 5bb53eb Jul 25, 2026
76 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

@yiliang114

Copy link
Copy Markdown
Collaborator

⚠️ Failed to process this request. Please re-mention the bot to retry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants