Skip to content

fix: support Qwen3.5 thinking mode and fix tool-call loop in openai_chat#2713

Open
jianzhichun wants to merge 1 commit into
farion1231:mainfrom
jianzhichun:fix/qwen3-thinking-tool-loop
Open

fix: support Qwen3.5 thinking mode and fix tool-call loop in openai_chat#2713
jianzhichun wants to merge 1 commit into
farion1231:mainfrom
jianzhichun:fix/qwen3-thinking-tool-loop

Conversation

@jianzhichun
Copy link
Copy Markdown

Closes #2712

Summary

  • Add preserveReasoningContent meta flag: when set, cc-switch echoes reasoning_content back in tool_result turns, fixing the infinite tool-call loop with Qwen3.5 and other thinking models served via vLLM/SGLang
  • Add disableThinking meta flag: injects chat_template_kwargs.enable_thinking=false into openai_chat requests for deployments where thinking is unnecessary (saves tokens)
  • Add 思考模式 / Thinking Mode toggle in provider Advanced settings UI, visible only when API format is openai_chat; ON → preserveReasoningContent, OFF → disableThinking

Changed Files

File Change
src-tauri/src/provider.rs Two new optional fields in ProviderMeta
src-tauri/src/proxy/providers/claude.rs Honour preserveReasoningContent flag in should_preserve_reasoning_content_for_openai_chat()
src-tauri/src/proxy/forwarder.rs Inject chat_template_kwargs.enable_thinking=false when disableThinking is set
src/types.ts Expose new meta fields to frontend
src/components/providers/forms/ClaudeFormFields.tsx Thinking Mode toggle UI
src/components/providers/forms/ProviderForm.tsx State management for new toggle

Test

Verified against Qwen3.5-397B-A17B-INT8 on vLLM 0.17 — tool calls complete correctly with preserveReasoningContent: true.

Qwen3.5 (and compatible models served via vLLM/SGLang) have two issues
when used through the openai_chat adapter:

1. Tool-call loop: when thinking is enabled, the model requires
   reasoning_content to be echoed back in the assistant message of
   subsequent tool_result turns. Without it the model repeats the same
   tool call indefinitely.

2. Token waste: thinking chain output is expensive and often unnecessary
   for agentic tasks.

Changes:
- provider.rs: add `preserveReasoningContent` and `disableThinking`
  fields to ProviderMeta
- claude.rs: honour `preserveReasoningContent` meta flag in
  should_preserve_reasoning_content_for_openai_chat(), falling back to
  "tool call" when no thinking content is present
- forwarder.rs: when `disableThinking` is set, inject
  chat_template_kwargs.enable_thinking=false into openai_chat requests
- types.ts: expose the two new meta fields to the frontend
- ClaudeFormFields.tsx + ProviderForm.tsx: add "思考模式" (Thinking Mode)
  toggle in Advanced options, visible only for openai_chat providers;
  ON → preserveReasoningContent, OFF → disableThinking
@farion1231
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 705761c977

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

</p>
</div>
<Switch
checked={thinkingEnabled ?? false}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist the shown OFF thinking state

The toggle renders undefined as unchecked (checked={thinkingEnabled ?? false}), but submit only writes disableThinking when the state is exactly false. This creates a hidden third state: users can see “OFF” and save without actually disabling thinking, so requests keep default behavior unexpectedly. In practice, a newly opened openai_chat provider can look disabled while no disableThinking flag is persisted.

Useful? React with 👍 / 👎.

Comment on lines +1236 to +1240
disableThinking:
appId === "claude" &&
category !== "official" &&
localApiFormat === "openai_chat" &&
localThinkingEnabled === false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset thinking preference when preset changes

localThinkingEnabled is reused when building meta.disableThinking / meta.preserveReasoningContent, but preset changes reset other Claude-local options and never reset this one. If a user toggles thinking on one preset and then switches to another openai_chat preset, the previous thinking flag is silently carried into the new provider payload, causing unintended behavior to be saved.

Useful? React with 👍 / 👎.

@farion1231
Copy link
Copy Markdown
Owner

@codex review
@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 12, 2026

Claude finished @farion1231's task in 2m 11s —— View job


Claude PR Review

  • Read changed Rust files
  • Read changed TS/TSX files
  • Cross-reference Codex findings & form logic
  • Post review

I confirmed both of the issues Codex raised — they reflect real UI/state inconsistencies, not pedantry. The Rust side looks clean.

Important — “Thinking Mode OFF” is a silent no-op for new providers

localThinkingEnabled is initialised to undefined for any new openai_chat provider (ProviderForm.tsx:367-374). The toggle then renders as visually OFF (checked={thinkingEnabled ?? false}) at ClaudeFormFields.tsx:590, and the description text below it actively reads "禁用思考链(节省 token)". However the submit branch only writes disableThinking: true when localThinkingEnabled === false:

disableThinking:
  appId === "claude" &&
  category !== "official" &&
  localApiFormat === "openai_chat" &&
  localThinkingEnabled === false   // undefined fails this check
    ? true
    : undefined,

Concrete failure: open the dialog, pick any openai_chat preset, fill in URL/key, hit save without touching the toggle. UI says thinking is disabled, but no flag is persisted, so the forwarder injection at forwarder.rs:1009-1029 never runs. Users will see tokens being burned on a <think> chain they think they turned off. The user has to flip ON then OFF to actually persist disableThinking, which is non-obvious.

Cleanest fix: pick a real default (e.g. initialise localThinkingEnabled to false for new openai_chat providers, or to whichever is the recommended default per the PR description) so the rendered state and the persisted state stay in sync. (Confidence: 92)

Important — localThinkingEnabled leaks across preset switches

handlePresetChange resets the other Claude-local toggles when picking a new preset (setLocalApiFormat, setLocalApiKeyField, setLocalIsFullUrl at ProviderForm.tsx:1522-1529) but never resets localThinkingEnabled. Also missing from the "custom" branch at lines 1373-1395.

Concrete scenario: user picks openai_chat preset A, toggles thinking ON (localThinkingEnabled = true), then changes their mind and picks openai_chat preset B. The new preset inherits preserveReasoningContent: true from the prior selection, silently, with no UI indication that anything carried over. Same shape of leak as the localApiKeyField bug that the surrounding lines were already coded to avoid — just an omission.

Fix: add setLocalThinkingEnabled(undefined) (or whatever the chosen default is) alongside the other resets in both branches of handlePresetChange. (Confidence: 86)

Rust side

No issues worth flagging. disable_thinking injection at forwarder.rs:1005-1029 correctly gates on openai_chat + Claude adapter and uses or_insert_with to avoid clobbering an existing chat_template_kwargs object. should_preserve_reasoning_content_for_openai_chat short-circuits on the meta flag before any model-name heuristic. Serde rename + skip_serializing_if = "Option::is_none" on the two new ProviderMeta fields keeps existing config files backward-compatible.


• branch fix/qwen3-thinking-tool-loop

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 705761c977

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

</p>
</div>
<Switch
checked={thinkingEnabled ?? false}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist explicit OFF when thinking switch is shown unchecked

The switch renders unchecked when thinkingEnabled is undefined (checked={thinkingEnabled ?? false}), but the submit path only writes disableThinking when localThinkingEnabled === false. That means a user can see “Thinking Mode OFF” and save without changing the toggle, yet no disableThinking flag is persisted, so the backend may still run with thinking enabled. This UI/state mismatch can silently produce behavior opposite to what the form indicates.

Useful? React with 👍 / 👎.

Comment on lines +366 to +370
const [localThinkingEnabled, setLocalThinkingEnabled] = useState<
boolean | undefined
>(() => {
if (appId !== "claude") return undefined;
const meta = initialData?.meta;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-sync thinking state when provider data/preset changes

localThinkingEnabled is initialized once from initialData and then treated as source of truth for submission, but unlike other local Claude fields it is not reset when preset/form data changes. In flows where the same form instance is reused (e.g., switching presets or loading a different provider), stale thinking state can leak into meta.disableThinking/meta.preserveReasoningContent and save unintended flags.

Useful? React with 👍 / 👎.

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.

Tool-call loop with Qwen3.5 / thinking models via openai_chat adapter

2 participants