Skip to content

feat(gateway): enforce reasoning effort allowlist - #3109

Closed
steebchen wants to merge 2 commits into
mainfrom
reasoning-effort-validation
Closed

steebchen wants to merge 2 commits into
mainfrom
reasoning-effort-validation

Conversation

@steebchen

@steebchen steebchen commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

What

When a request specifies a reasoning_effort (or reasoning.effort) that is not in a model mapping's declared reasoningEfforts list, the gateway now rejects it with a 400 and records a client_error log so the rejected request shows up in the activity feed. Previously the value was forwarded blindly and only failed upstream (for enum-based providers) or was silently translated (for budget-based providers) — the reasoningEfforts field was purely informational metadata.

Behavior

  • Mapping declares a list (e.g. ["low","medium","high"]) and user requests "xhigh" → 400 + client_error log (cause unsupported_reasoning_effort).
  • Mapping declares no list → all efforts pass through unchanged. We never assume defaults, so the gateway can't 400 a request that could have worked upstream.
  • Multiple candidate providers (no pinned provider) → only reject when every reasoning-capable mapping declares a list that excludes the value. If any candidate has no list, or includes the value, it passes through.
  • Pinned provider (provider/model) → checked against that provider's list only.
  • auto/custom models and non-reasoning models keep their existing behavior.

Changes

  • validate-model-capabilities.ts — new allowlist check after the reasoning-support gate; throws HTTPException(400, { cause: "unsupported_reasoning_effort" }).
  • chat.ts — generalized the existing capability-error catch so both the Anthropic budget-thinking case and the new effort case insert a client_error log via one shared _insertLog call.
  • completions.ts / models.ts — updated the reasoning_effort schema docs and the reasoningEfforts field doc to describe the new enforcement.
  • Added unit tests covering allow/reject/pass-through/pinned/auto cases (27 total in the spec, all passing).

pnpm format and a full pnpm build pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added validation for requested reasoning-effort levels based on model and provider capabilities.
    • Unsupported reasoning-effort values now return clear HTTP 400 errors listing supported options.
    • Models without declared effort limits continue forwarding requested values unchanged.
  • Bug Fixes

    • Improved client-visible errors and activity logs for unsupported reasoning and Anthropic budget-thinking configurations.
  • Documentation

    • Clarified reasoning-effort behavior in API documentation and model capability guidance.

Copilot AI review requested due to automatic review settings July 17, 2026 19:30
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@steebchen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bf5ef7a-fbd7-4bf4-93ed-7b2d614041c1

📥 Commits

Reviewing files that changed from the base of the PR and between 12934b0 and 9952ee1.

📒 Files selected for processing (1)
  • apps/gateway/src/chat/tools/validate-model-capabilities.ts

Walkthrough

The gateway now validates requested reasoning-effort tiers against provider metadata, documents the behavior in schemas and model definitions, tests supported and unsupported cases, and returns structured capability errors with matching activity-feed entries.

Changes

Reasoning effort validation and error reporting

Layer / File(s) Summary
Reasoning effort allowlist validation
apps/gateway/src/chat/tools/validate-model-capabilities.ts, apps/gateway/src/chat/tools/validate-model-capabilities.spec.ts, apps/gateway/src/chat/schemas/completions.ts, packages/models/src/models.ts
Reasoning-effort requests are checked against provider-declared tiers, with unrestricted pass-through for missing or empty lists; schemas, model metadata comments, and tests document and verify the behavior.
Structured capability error propagation
apps/gateway/src/chat/chat.ts
Capability failures now derive client-visible messages and causes for activity-feed logging and HTTP 400 responses, including unsupported reasoning effort and Anthropic adaptive-thinking cases.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: ratchaw

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant CompletionsRoute
  participant ValidateModelCapabilities
  participant ActivityFeed
  Client->>CompletionsRoute: submit reasoning_effort
  CompletionsRoute->>ValidateModelCapabilities: validate model capabilities
  ValidateModelCapabilities-->>CompletionsRoute: unsupported_reasoning_effort
  CompletionsRoute->>ActivityFeed: write client_error
  CompletionsRoute-->>Client: HTTP 400 with capability message
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: enforcing a reasoning effort allowlist in the gateway.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reasoning-effort-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Reject a requested reasoning_effort with a 400 (and a client_error log)
when it falls outside a mapping's declared reasoningEfforts list. When a
mapping declares no list, all efforts pass through unchanged so the
gateway never 400s a request that could have succeeded upstream. With
multiple candidate providers, only reject when every reasoning-capable
mapping declares a list that excludes the value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen
steebchen force-pushed the reasoning-effort-validation branch from 85f57f6 to 12934b0 Compare July 28, 2026 10:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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)
apps/gateway/src/chat/tools/validate-model-capabilities.spec.ts (1)

250-309: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider self-contained fixtures instead of scanning the live models catalog.

declaredModel/undeclaredModel are derived from production model metadata at module-evaluation time, so these tests silently change meaning as mappings evolve, and if the predicates ever stop matching the suite fails during collection with an opaque Error rather than as a test failure. The rest of this spec uses local ModelDefinition literals (noVisionModel, verbosityModel), which keeps the allowlist assertions deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/tools/validate-model-capabilities.spec.ts` around lines
250 - 309, Replace the live-catalog scans used to initialize declaredModel and
undeclaredModel with explicit local ModelDefinition fixtures, mirroring the
existing noVisionModel and verbosityModel patterns. Ensure one fixture has
reasoning-capable providers with non-empty reasoningEfforts and a missing global
tier, while the other includes a reasoning-capable provider without a declared
list; derive supportedEffort, unsupportedEffort, and pinnedProvider from those
fixtures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 2679-2684: Update the capability-rejection path around
clientErrorCause and the HTTPException throw to preserve the structured error
code in the HTTP response. Match the existing service-tier/guardrail response
handling by returning the structured OpenAI error directly, or propagate
clientErrorCause through the global handler, while retaining the current
rejection status and message.

---

Nitpick comments:
In `@apps/gateway/src/chat/tools/validate-model-capabilities.spec.ts`:
- Around line 250-309: Replace the live-catalog scans used to initialize
declaredModel and undeclaredModel with explicit local ModelDefinition fixtures,
mirroring the existing noVisionModel and verbosityModel patterns. Ensure one
fixture has reasoning-capable providers with non-empty reasoningEfforts and a
missing global tier, while the other includes a reasoning-capable provider
without a declared list; derive supportedEffort, unsupportedEffort, and
pinnedProvider from those fixtures.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d5bdb52a-46e2-4e9d-b152-69f258edb570

📥 Commits

Reviewing files that changed from the base of the PR and between 2f5d998 and 12934b0.

📒 Files selected for processing (5)
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/schemas/completions.ts
  • apps/gateway/src/chat/tools/validate-model-capabilities.spec.ts
  • apps/gateway/src/chat/tools/validate-model-capabilities.ts
  • packages/models/src/models.ts

Comment on lines +2679 to +2684
logger.error("Failed to log capability rejection", {
error: toError(error),
cause: clientErrorCause,
});
}
throw new HTTPException(400, { message });
throw new HTTPException(400, { message: clientErrorMessage });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# How does the global error handler build the client error body (code/param) from HTTPException?
fd -t f 'error' apps/gateway/src --extension ts | head -50
rg -nP --type=ts -C6 'invalid_request_error' apps/gateway/src | head -120
rg -nP --type=ts -C8 '\bcause\b' apps/gateway/src --glob '!**/*.spec.ts' | rg -nC6 'HTTPException|onError' | head -120

Repository: theopenco/llmgateway

Length of output: 17351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== app.ts error handler section ==="
sed -n '200,255p' apps/gateway/src/app.ts | cat -n

echo "=== error-response.ts ==="
cat -n apps/gateway/src/lib/error-response.ts

echo "=== app.ts renderGatewayError references/typescript usages ==="
rg -nP --type=ts -C8 'function renderGatewayError|renderGatewayError|onError|if \(error instanceof HTTPException\)' apps/gateway/src | head -200

echo "=== relevant chat.ts validation and capability catch ==="
sed -n '2390,2445p' apps/gateway/src/chat/chat.ts | cat -n
sed -n '2550,2690p' apps/gateway/src/chat/chat.ts | cat -n

echo "=== app.ts imports ==="
sed -n '1,80p' apps/gateway/src/app.ts | cat -n

echo "=== package references to hono/http-exception ==="
rg -n 'hono/http-exception|HTTPException' apps/gateway/package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null | head -80 || true

echo "=== app.spec relevant lines ==="
sed -n '1060,1125p' apps/gateway/src/api.spec.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 34539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== renderGatewayError implementation ==="
rg -n --type=ts -C8 'function renderGatewayError|const renderGatewayError|export .*renderGatewayError' apps/gateway/src

echo "=== broader renderGatewayError usages near app errors ==="
rg -n --type=ts -C4 'renderGatewayError\(c, error' apps/gateway/src

echo "=== app.ts all lines around onError start ==="
rg -n --type=ts -C6 'onError:|onError' apps/gateway/src/app.ts
sed -n '1,80p' apps/gateway/src/app.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 5372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== service tier validation throw location ==="
rg -n --type=ts -C6 'unsupported_service_tier|service_tier' apps/gateway/src --glob '**/*.ts'

echo "=== app.ts renderGatewayError body ==="
sed -n '139,200p' apps/gateway/src/app.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== exact chat service_tier throw block ==="
sed -n '2099,2168p' apps/gateway/src/chat/chat.ts | cat -n

echo "=== exact chat reasoning_effort throw block ==="
sed -n '2280,2345p' apps/gateway/src/chat/chat.ts | cat -n

echo "=== exact app renderGatewayError body ==="
sed -n '168,199p' apps/gateway/src/app.ts | cat -n

echo "=== exact client response parsing for code/param ==="
rg -n --type=ts -C4 'unsupported_service_tier|code.*param|error.code|error.code\(' apps/gateway/src/api.spec.ts apps/gateway/src -g '*.ts' | head -240

Repository: theopenco/llmgateway

Length of output: 22097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== service tier throw complete ==="
sed -n '2107,2182p' apps/gateway/src/chat/chat.ts | cat -n

echo "=== reasoning effort throw complete ==="
sed -n '2320,2380p' apps/gateway/src/chat/chat.ts | cat -n

echo "=== read-only probe: global handler body for service_tier/effort code values ==="
python3 - <<'PY'
from pathlib import Path
app = Path("apps/gateway/src/app.ts").read_text()
error_response = Path("apps/gateway/src/lib/error-response.ts").read_text()
chat = Path("apps/gateway/src/chat/chat.ts").read_text()

for needle in ["new HTTPException(400, {", ".cause", "BuildOpenAIErrorBody", "buildOpenAIErrorBody"]:
    print(f"{needle}: {needle in error_response or needle in app or needle in chat}")

print("app onError uses renderGatewayError for HTTPException:", "return renderGatewayError(c, status, error.message)" in app or "renderGatewayError(c, error.statusCode, error.message)" in app)
print("renderGatewayError params:", "function renderGatewayError(" in app and "message: string" in app)
print("buildOpenAIErrorBody ignores cause:", "cause" not in error_response or "function buildOpenAIErrorBody(opts" in error_response and "code: opts.code !== undefined ? opts.code : meta.code" in error_response)

start = chat.find("if (serviceTierSupportedProviders.length === 0)")
end = chat.find("throw new HTTPException", start)
print("\nservice_tier throw snippet:")
for i, line in enumerate(chat[start:end+400].splitlines(), 1):
    if i <= 90:
        print(f"{i}: {line}")
PY

Repository: theopenco/llmgateway

Length of output: 7934


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '2170,2192p' apps/gateway/src/chat/chat.ts | cat -n
sed -n '2370,2436p' apps/gateway/src/chat/chat.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 2971


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '2180,2198p' apps/gateway/src/chat/chat.ts | cat -n
sed -n '2420,2442p' apps/gateway/src/chat/chat.ts | cat -n
sed -n '2674,2690p' apps/gateway/src/chat/chat.ts | cat -n

Repository: theopenco/llmgateway

Length of output: 1997


Preserve the capability-rejection code in the HTTP exception.

This code path records clientErrorCause but rethrows a plain 400 body, so OpenAI clients see error.code: null while Anthropic clients already have no code in their envelope. Return the structured OpenAI error directly, like the service-tier/guardrail paths, or carry the cause through the global handler.

🔧 Proposed fix
-			throw new HTTPException(400, { message: clientErrorMessage });
+			return c.json({
+				error: {
+					message: clientErrorMessage,
+					type: clientErrorCause === "unsupported_service_tier"
+						? "invalid_request_error"
+						: clientErrorCause,
+					param: clientErrorCause === "unsupported_service_tier" ? "service_tier" : null,
+					code: clientErrorCause === "unsupported_service_tier" ? "unsupported_service_tier" : null,
+				},
+			}, 400);
📝 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
logger.error("Failed to log capability rejection", {
error: toError(error),
cause: clientErrorCause,
});
}
throw new HTTPException(400, { message });
throw new HTTPException(400, { message: clientErrorMessage });
logger.error("Failed to log capability rejection", {
error: toError(error),
cause: clientErrorCause,
});
}
return c.json({
error: {
message: clientErrorMessage,
type: clientErrorCause === "unsupported_service_tier"
? "invalid_request_error"
: clientErrorCause,
param: clientErrorCause === "unsupported_service_tier" ? "service_tier" : null,
code: clientErrorCause === "unsupported_service_tier" ? "unsupported_service_tier" : null,
},
}, 400);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/chat.ts` around lines 2679 - 2684, Update the
capability-rejection path around clientErrorCause and the HTTPException throw to
preserve the structured error code in the HTTP response. Match the existing
service-tier/guardrail response handling by returning the structured OpenAI
error directly, or propagate clientErrorCause through the global handler, while
retaining the current rejection status and message.

@steebchen

Copy link
Copy Markdown
Member Author

Closing for now — not necessary at the moment. Branch and work preserved; can reopen if we decide to enforce the reasoning-effort allowlist later.

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