Skip to content

fix: OpenAI adapter tool calling compatibility#149

Merged
claude-code-best merged 1 commit intoclaude-code-best:mainfrom
uk0:fix/openai-tool-compat
Apr 6, 2026
Merged

fix: OpenAI adapter tool calling compatibility#149
claude-code-best merged 1 commit intoclaude-code-best:mainfrom
uk0:fix/openai-tool-compat

Conversation

@uk0
Copy link
Copy Markdown
Contributor

@uk0 uk0 commented Apr 6, 2026

Summary

Fixes two OpenAI-compatible provider compatibility issues identified by comparing with the CCB proxy implementation:

  • JSON Schema const sanitization: Recursively convert const: valueenum: [value] in tool parameter schemas. Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) reject const in JSON Schema, causing tool definitions to fail silently.
  • Force tool_use stop_reason when tool_calls present: Some backends return finish_reason: "stop" even when the response contains tool_calls. Without this fix, the query loop treats it as end_turn and never executes the tools — the model's tool call is silently dropped.

Files changed

  • src/services/api/openai/convertTools.ts — add sanitizeJsonSchema() recursive processor
  • src/services/api/openai/streamAdapter.ts — override stop_reason when toolBlocks.size > 0
  • Tests: 4 new test cases covering both fixes

Test plan

  • 77/77 API adapter tests pass (4 new)
  • const in top-level, nested, and anyOf/oneOf/allOf schemas all converted correctly
  • finish_reason: "stop" with tool_calls now correctly yields stop_reason: "tool_use"
  • Normal finish_reason: "stop" without tools still yields end_turn

Summary by CodeRabbit

Bug Fixes

  • Improved tool schema compatibility for OpenAI integration by standardizing JSON Schema format handling.
  • Enhanced detection of tool invocations in streaming responses to ensure correct stop reason signaling.

Tests

  • Added test coverage for schema format conversion and tool invocation detection in streaming scenarios.

Two fixes for OpenAI-compatible provider compatibility:

1. Sanitize JSON Schema `const` → `enum` in tool parameters.
   Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.)
   do not support the `const` keyword in JSON Schema. Recursively
   convert `const: value` to `enum: [value]` which is semantically
   equivalent.

2. Force stop_reason to `tool_use` when tool_calls are present.
   Some backends incorrectly return finish_reason "stop" even when
   the response contains tool_calls. Without this fix, the query
   loop treats the response as a normal end_turn and never executes
   the requested tools.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

The changes add JSON schema sanitization for Anthropic-to-OpenAI tool conversion, automatically replacing const keywords with enum equivalents through recursive schema traversal, and enhance stream adaptation to detect tool calls and signal correct stop reasons based on their presence.

Changes

Cohort / File(s) Summary
Schema Sanitization Logic
src/services/api/openai/convertTools.ts
Added sanitizeJsonSchema() helper function that recursively transforms JSON Schema const fields to enum: [value] across nested structures (properties, definitions, anyOf/oneOf/allOf, etc.) before passing parameters to OpenAI-compatible tools.
Schema Sanitization Tests
src/services/api/openai/__tests__/convertTools.test.ts
Added three test cases validating const-to-enum conversion in direct properties, deeply nested schemas with definitions, and within schema combinators like anyOf.
Stream Adaptation Logic
src/services/api/openai/streamAdapter.ts
Modified finish-reason handling to derive Anthropic stop_reason from presence of tool calls (toolBlocks.size > 0) rather than unconditionally mapping OpenAI finish_reason, forcing 'tool_use' when tool calls exist.
Stream Adaptation Tests
src/services/api/openai/__tests__/streamAdapter.test.ts
Added test case validating that tool_use stop_reason is forced when tool calls are present despite incoming finish_reason: 'stop'.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Schemas now sanitized, so const becomes enum,
Tool calls flash their signals, no stop signs confused,
Anthropic translates to OpenAI's tongue,
Streams adapt and flow true, each reason now used! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: OpenAI adapter tool calling compatibility' is clear, specific, and directly related to the main changes in the PR, which address two tool calling compatibility issues with OpenAI-compatible providers.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/services/api/openai/__tests__/streamAdapter.test.ts (1)

168-188: Add a non-stop regression case for tool calls.

This new test is great for the "stop" path; please add one for finish_reason: "length" + tool_calls to ensure only the "stop" case is overridden.

🧪 Suggested additional test
+  test('keeps max_tokens stop_reason when tool_calls present and finish_reason is length', async () => {
+    const events = await collectEvents([
+      makeChunk({
+        choices: [{
+          index: 0,
+          delta: {
+            tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
+          },
+          finish_reason: null,
+        }],
+      }),
+      makeChunk({
+        choices: [{ index: 0, delta: {}, finish_reason: 'length' }],
+      }),
+    ])
+
+    const msgDelta = events.find(e => e.type === 'message_delta') as any
+    expect(msgDelta.delta.stop_reason).toBe('max_tokens')
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api/openai/__tests__/streamAdapter.test.ts` around lines 168 -
188, Add a sibling test that mirrors the existing "forces tool_use stop_reason
when tool_calls present but finish_reason is stop" case but uses finish_reason:
'length' instead of 'stop' to assert we do NOT override the stop_reason; use
collectEvents and makeChunk the same way, then find the message_delta (msgDelta)
and expect msgDelta.delta.stop_reason to be 'length' (or unchanged) to ensure
only the 'stop' finish_reason path is forced to 'tool_use'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/services/api/openai/streamAdapter.ts`:
- Around line 264-265: The current logic sets stopReason = hasToolCalls ?
'tool_use' : mapFinishReason(choice.finish_reason) regardless of the original
finish reason; change this so the 'tool_use' override is applied only when
toolBlocks.size > 0 AND choice.finish_reason === 'stop' (otherwise preserve
mapped reasons like 'length' or 'content_filter'). In other words, update the
computation of stopReason (symbols: toolBlocks, hasToolCalls, stopReason,
mapFinishReason, choice.finish_reason) so it checks choice.finish_reason ===
'stop' before substituting 'tool_use', and fall back to
mapFinishReason(choice.finish_reason) for all other finish reasons.

---

Nitpick comments:
In `@src/services/api/openai/__tests__/streamAdapter.test.ts`:
- Around line 168-188: Add a sibling test that mirrors the existing "forces
tool_use stop_reason when tool_calls present but finish_reason is stop" case but
uses finish_reason: 'length' instead of 'stop' to assert we do NOT override the
stop_reason; use collectEvents and makeChunk the same way, then find the
message_delta (msgDelta) and expect msgDelta.delta.stop_reason to be 'length'
(or unchanged) to ensure only the 'stop' finish_reason path is forced to
'tool_use'.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 15edcb8c-6f1d-4756-bb44-266c66c0ec41

📥 Commits

Reviewing files that changed from the base of the PR and between 919011a and e88dcb2.

📒 Files selected for processing (4)
  • src/services/api/openai/__tests__/convertTools.test.ts
  • src/services/api/openai/__tests__/streamAdapter.test.ts
  • src/services/api/openai/convertTools.ts
  • src/services/api/openai/streamAdapter.ts

Comment on lines +264 to +265
const hasToolCalls = toolBlocks.size > 0
const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Scope the tool_use override to finish_reason === 'stop' only.

Current logic overrides length/content_filter too when tool blocks exist, which can misreport truncated/filtered completions as executable tool calls.

💡 Proposed fix
-      const hasToolCalls = toolBlocks.size > 0
-      const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
+      const hasToolCalls = toolBlocks.size > 0
+      const mappedStopReason = mapFinishReason(choice.finish_reason)
+      const stopReason =
+        hasToolCalls && choice.finish_reason === 'stop'
+          ? 'tool_use'
+          : mappedStopReason
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasToolCalls = toolBlocks.size > 0
const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
const hasToolCalls = toolBlocks.size > 0
const mappedStopReason = mapFinishReason(choice.finish_reason)
const stopReason =
hasToolCalls && choice.finish_reason === 'stop'
? 'tool_use'
: mappedStopReason
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api/openai/streamAdapter.ts` around lines 264 - 265, The current
logic sets stopReason = hasToolCalls ? 'tool_use' :
mapFinishReason(choice.finish_reason) regardless of the original finish reason;
change this so the 'tool_use' override is applied only when toolBlocks.size > 0
AND choice.finish_reason === 'stop' (otherwise preserve mapped reasons like
'length' or 'content_filter'). In other words, update the computation of
stopReason (symbols: toolBlocks, hasToolCalls, stopReason, mapFinishReason,
choice.finish_reason) so it checks choice.finish_reason === 'stop' before
substituting 'tool_use', and fall back to mapFinishReason(choice.finish_reason)
for all other finish reasons.

@claude-code-best claude-code-best merged commit 6e598fc into claude-code-best:main Apr 6, 2026
3 checks passed
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.

2 participants