-
Notifications
You must be signed in to change notification settings - Fork 11
feat(gateway): add Anthropic Messages API support to openrouter proxy #1122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d3725c9
09608f9
a84d93d
f2c7922
a2b857c
d29c458
f23b160
4fc3153
466d085
2aa4e86
7b56798
ac58ad0
32c1e35
e8761fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { validateFeatureHeader, FEATURE_HEADER } from '@/lib/feature-detection'; | |
| import type { | ||
| OpenRouterChatCompletionRequest, | ||
| GatewayResponsesRequest, | ||
| GatewayMessagesRequest, | ||
| GatewayRequest, | ||
| } from '@/lib/providers/openrouter/types'; | ||
| import { applyProviderSpecificLogic, getProvider, openRouterRequest } from '@/lib/providers'; | ||
|
|
@@ -69,6 +70,7 @@ import { applyResolvedAutoModel, isKiloAutoModel } from '@/lib/kilo-auto-model'; | |
| import { fixOpenCodeDuplicateReasoning } from '@/lib/providers/fixOpenCodeDuplicateReasoning'; | ||
| import type { MicrodollarUsageContext, PromptInfo } from '@/lib/processUsage.types'; | ||
| import { extractResponsesPromptInfo } from '@/lib/processUsage.responses'; | ||
| import { extractMessagesPromptInfo } from '@/lib/processUsage.messages'; | ||
| import { getMaxTokens, hasMiddleOutTransform } from '@/lib/providers/openrouter/request-helpers'; | ||
| import { isKiloAffiliatedUser } from '@/lib/isKiloAffiliatedUser'; | ||
|
|
||
|
|
@@ -82,13 +84,17 @@ const PROMOTION_MODEL_LIMIT_REACHED = 'PROMOTION_MODEL_LIMIT_REACHED'; | |
| function validatePath( | ||
| url: URL | ||
| ): | ||
| | { path: '/chat/completions' | '/responses' } | ||
| | { path: '/chat/completions' | '/responses' | '/messages' } | ||
| | { errorResponse: ReturnType<typeof invalidPathResponse> } { | ||
| const pathSuffix = | ||
| stripRequiredPrefix(url.pathname, '/api/gateway') ?? | ||
| stripRequiredPrefix(url.pathname, '/api/openrouter'); | ||
|
|
||
| if (pathSuffix === '/chat/completions' || pathSuffix === '/responses') { | ||
| if ( | ||
| pathSuffix === '/chat/completions' || | ||
| pathSuffix === '/responses' || | ||
| pathSuffix === '/messages' | ||
| ) { | ||
| return { path: pathSuffix }; | ||
| } | ||
| return { errorResponse: invalidPathResponse() }; | ||
|
|
@@ -113,6 +119,9 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
| // Inject or merge stream_options.include_usage = true | ||
| body.stream_options = { ...(body.stream_options || {}), include_usage: true }; | ||
| requestBodyParsed = { kind: 'chat_completions', body }; | ||
| } else if (path === '/messages') { | ||
| const body: GatewayMessagesRequest = JSON.parse(requestBodyText); | ||
| requestBodyParsed = { kind: 'messages', body }; | ||
| } else { | ||
| const body: GatewayResponsesRequest = JSON.parse(requestBodyText); | ||
| body.store = false; | ||
|
|
@@ -236,13 +245,13 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
| } | ||
|
|
||
| if ( | ||
| requestBodyParsed.kind === 'responses' && | ||
| ['messages', 'responses'].includes(requestBodyParsed.kind) && | ||
| !isKiloAffiliatedUser(maybeUser, organizationId ?? null) | ||
| ) { | ||
| return NextResponse.json( | ||
| { | ||
| error: { | ||
| message: 'The Responses API is experimental and not yet available to all users.', | ||
| message: `The ${requestBodyParsed.kind} API is experimental and not yet available to all users.`, | ||
| }, | ||
| }, | ||
| { status: 403 } | ||
|
|
@@ -309,7 +318,9 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
| const promptInfo: PromptInfo = | ||
| requestBodyParsed.kind === 'chat_completions' | ||
| ? extractPromptInfo(requestBodyParsed.body) | ||
| : extractResponsesPromptInfo(requestBodyParsed.body); | ||
| : requestBodyParsed.kind === 'messages' | ||
| ? extractMessagesPromptInfo(requestBodyParsed.body) | ||
| : extractResponsesPromptInfo(requestBodyParsed.body); | ||
|
|
||
| const usageContext: MicrodollarUsageContext = { | ||
| api_kind: requestBodyParsed.kind, | ||
|
|
@@ -387,14 +398,23 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
| return dataCollectionRequiredResponse(); | ||
| } | ||
|
|
||
| if (taskId) { | ||
| requestBodyParsed.body.prompt_cache_key = generateProviderSpecificHash( | ||
| user.id + taskId, | ||
| provider | ||
| ); | ||
| const userId = generateProviderSpecificHash(user.id, provider); | ||
| if (requestBodyParsed.kind === 'messages') { | ||
| requestBodyParsed.body.metadata = { user_id: userId }; | ||
| requestBodyParsed.body.user = userId; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Non-standard Messages fields are sent to Anthropic-compatible backends
|
||
| if (taskId) { | ||
| requestBodyParsed.body.session_id = generateProviderSpecificHash(user.id + taskId, provider); | ||
| } | ||
| } else { | ||
| if (taskId) { | ||
| requestBodyParsed.body.prompt_cache_key = generateProviderSpecificHash( | ||
| user.id + taskId, | ||
| provider | ||
| ); | ||
| } | ||
| requestBodyParsed.body.safety_identifier = userId; | ||
| requestBodyParsed.body.user = userId; // deprecated, but this is what OpenRouter uses | ||
| } | ||
| requestBodyParsed.body.safety_identifier = generateProviderSpecificHash(user.id, provider); | ||
| requestBodyParsed.body.user = requestBodyParsed.body.safety_identifier; // deprecated, but this is what OpenRouter uses | ||
|
|
||
| if (requestBodyParsed.kind === 'chat_completions') { | ||
| if (ENABLE_TOOL_REPAIR) { | ||
|
|
@@ -422,9 +442,11 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
|
|
||
| let response: Response; | ||
| if (customLlm) { | ||
| if (requestBodyParsed.kind === 'responses') { | ||
| if (requestBodyParsed.kind === 'responses' || requestBodyParsed.kind === 'messages') { | ||
chrarnoldus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return NextResponse.json( | ||
| { error: 'This model is not yet available on the Responses API' }, | ||
| { | ||
| error: `This model is not available on the ${requestBodyParsed.kind} API`, | ||
| }, | ||
| { status: 404 } | ||
| ); | ||
| } | ||
|
|
@@ -548,9 +570,13 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno | |
| (isKiloFreeModel(originalModelIdLowerCased) || | ||
| isActiveReviewPromo(botId, originalModelIdLowerCased)) | ||
| ) { | ||
| return requestBodyParsed.kind === 'chat_completions' | ||
| ? rewriteFreeModelResponse_ChatCompletions(response, originalModelIdLowerCased) | ||
| : rewriteFreeModelResponse_Responses(response, originalModelIdLowerCased); | ||
| if (requestBodyParsed.kind === 'chat_completions') { | ||
| return rewriteFreeModelResponse_ChatCompletions(response, originalModelIdLowerCased); | ||
| } | ||
| if (requestBodyParsed.kind === 'responses') { | ||
| return rewriteFreeModelResponse_Responses(response, originalModelIdLowerCased); | ||
| } | ||
| // messages kind: pass through as-is (free models don't currently use the Messages API) | ||
| } | ||
|
|
||
| return wrapInSafeNextResponse(response); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ import 'server-only'; | |
| import { getMaxTokens, hasMiddleOutTransform } from '@/lib/providers/openrouter/request-helpers'; | ||
|
|
||
| /** | ||
| * Extract full prompts from a GatewayRequest (chat completions or responses API). | ||
| * Extract full prompts from a GatewayRequest (chat completions, responses, or messages API). | ||
| * Unlike extractPromptInfo (which truncates to 100 chars), this returns full content for abuse analysis. | ||
| */ | ||
| function extractFullPrompts(request: GatewayRequest): { | ||
|
|
@@ -31,6 +31,30 @@ function extractFullPrompts(request: GatewayRequest): { | |
| if (request.kind === 'responses') { | ||
| return extractFullPromptsFromResponses(request.body); | ||
| } | ||
| if (request.kind === 'messages') { | ||
| const systemContent = request.body.system; | ||
| const systemPrompt = | ||
| typeof systemContent === 'string' | ||
| ? systemContent | ||
| : Array.isArray(systemContent) | ||
| ? systemContent.map(b => b.text).join('\n') | ||
| : null; | ||
| const lastUserMessage = request.body.messages.filter(m => m.role === 'user').at(-1); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Tool-result turns erase the user prompt for abuse checks Anthropic tool loops send tool results as a |
||
| let userPrompt: string | null = null; | ||
| if (lastUserMessage) { | ||
| const content = lastUserMessage.content; | ||
| if (typeof content === 'string') { | ||
| userPrompt = content; | ||
| } else if (Array.isArray(content)) { | ||
| userPrompt = | ||
| content | ||
| .filter(c => c.type === 'text') | ||
| .map(c => ('text' in c ? c.text : '')) | ||
| .join('\n') || null; | ||
| } | ||
| } | ||
| return { systemPrompt: systemPrompt || null, userPrompt }; | ||
| } | ||
| return extractFullPromptsFromChatCompletions(request.body); | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.