fix(applicability): match all proxy Anthropic message routes

shouldTransformAnthropicMessages() gated the path with
!path.endsWith('/v1/messages'), but createProxy() routes three exact paths:
/v1/messages, /anthropic/v1/messages, and /anthropic/messages. So an SDK/host
using the public helper to pre-gate got unsupported_path on /anthropic/messages
— a request the proxy actually transforms — while endsWith would also have
wrongly accepted an unrelated /foo/v1/messages.

- lift isAnthropicMessagesPath() into applicability.ts (proxy.ts already imports
  from it; no cycle) as the single source of truth
- proxy.ts imports it instead of its own copy
- shouldTransformAnthropicMessages() uses it, so helper and router agree
- public-api tests cover /anthropic/v1/messages and /anthropic/messages
  (eligible) plus /v1/messages/count_tokens (still unsupported_path)
This commit is contained in:
Danil Silantyev
2026-07-04 12:49:44 +07:00
committed by Steven
parent aff960f025
commit 0e4a61695f
3 changed files with 24 additions and 8 deletions
+13 -1
View File
@@ -90,13 +90,25 @@ export function isPxpipeSupportedGptModel(model: string | null | undefined): boo
return isAllowed(model);
}
/** Canonical set of Anthropic Messages routes pxpipe transforms. Shared with
* createProxy (src/core/proxy.ts) so the public applicability helper and the
* proxy router can never disagree on which paths are eligible — they did: the
* proxy accepts /anthropic/messages, but the helper's old `endsWith` check
* rejected it (and would have wrongly accepted /foo/v1/messages). Exact matches
* only, so /v1/messages/count_tokens stays unsupported. */
export function isAnthropicMessagesPath(pathname: string): boolean {
return pathname === '/v1/messages'
|| pathname === '/anthropic/v1/messages'
|| pathname === '/anthropic/messages';
}
export function shouldTransformAnthropicMessages(
input: PxpipeApplicabilityInput,
): { eligible: boolean; reason: PxpipeApplicabilityReason } {
if (input.method !== undefined && input.method !== null && input.method.toUpperCase() !== 'POST') {
return { eligible: false, reason: 'unsupported_method' };
}
if (input.path !== undefined && input.path !== null && !input.path.endsWith('/v1/messages')) {
if (input.path !== undefined && input.path !== null && !isAnthropicMessagesPath(input.path)) {
return { eligible: false, reason: 'unsupported_path' };
}
if (input.bodyBytes !== undefined && input.bodyBytes !== null && input.bodyBytes <= 0) {
+1 -7
View File
@@ -5,7 +5,7 @@
import { transformRequest, type TransformOptions, type TransformInfo } from './transform.js';
import { transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js';
import { isPxpipeSupportedGptModel, isPxpipeSupportedModel } from './applicability.js';
import { isAnthropicMessagesPath, isPxpipeSupportedGptModel, isPxpipeSupportedModel } from './applicability.js';
import {
buildBaselineCountTokensBody,
buildCacheablePrefixCountTokensBody,
@@ -508,12 +508,6 @@ function isProviderPrefixedPath(pathname: string): boolean {
return PASSTHROUGH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function isAnthropicMessagesPath(pathname: string): boolean {
return pathname === '/v1/messages'
|| pathname === '/anthropic/v1/messages'
|| pathname === '/anthropic/messages';
}
function isOpenAIChatPath(pathname: string): boolean {
return pathname === '/v1/chat/completions' || pathname === '/openai/v1/chat/completions';
}
+10
View File
@@ -143,6 +143,16 @@ describe('public library API', () => {
path: '/v1/messages',
bodyBytes: 10,
}).reason).toBe('unsupported_method');
// Provider-prefixed routes createProxy() also transforms must be eligible
// here too — the old endsWith('/v1/messages') check rejected /anthropic/messages.
for (const path of ['/anthropic/v1/messages', '/anthropic/messages']) {
expect(shouldTransformAnthropicMessages({
model: 'claude-fable-5',
method: 'POST',
path,
bodyBytes: 10,
})).toEqual({ eligible: true, reason: 'eligible' });
}
expect(shouldTransformAnthropicMessages({
model: 'claude-fable-5',
method: 'POST',