diff --git a/restored-src/src/services/api/claude.ts b/restored-src/src/services/api/claude.ts index 89a6e6619..491e1e17e 100644 --- a/restored-src/src/services/api/claude.ts +++ b/restored-src/src/services/api/claude.ts @@ -100,6 +100,7 @@ import { extractQuotaStatusFromHeaders, } from '../claudeAiLimits.js' import { getAPIContextManagement } from '../compact/apiMicrocompact.js' +import { normalizeMiniMaxVideoContent } from './minimaxVideoInput.js' /* eslint-disable @typescript-eslint/no-require-imports */ const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') @@ -591,14 +592,15 @@ export function userMessageToMessageParam( enablePromptCaching: boolean, querySource?: QuerySource, ): MessageParam { + const content = normalizeMiniMaxVideoContent(message.message.content) if (addCache) { - if (typeof message.message.content === 'string') { + if (typeof content === 'string') { return { role: 'user', content: [ { type: 'text', - text: message.message.content, + text: content, ...(enablePromptCaching && { cache_control: getCacheControl({ querySource }), }), @@ -608,9 +610,9 @@ export function userMessageToMessageParam( } else { return { role: 'user', - content: message.message.content.map((_, i) => ({ + content: content.map((_, i) => ({ ..._, - ...(i === message.message.content.length - 1 + ...(i === content.length - 1 ? enablePromptCaching ? { cache_control: getCacheControl({ querySource }) } : {} @@ -624,9 +626,7 @@ export function userMessageToMessageParam( // to addCacheBreakpoints share the same array and each splices in duplicate cache_edits. return { role: 'user', - content: Array.isArray(message.message.content) - ? [...message.message.content] - : message.message.content, + content: Array.isArray(content) ? [...content] : content, } } diff --git a/restored-src/src/services/api/minimaxVideoInput.test.ts b/restored-src/src/services/api/minimaxVideoInput.test.ts new file mode 100644 index 000000000..685e4e586 --- /dev/null +++ b/restored-src/src/services/api/minimaxVideoInput.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { BetaContentBlockParam } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { normalizeMiniMaxVideoContent } from './minimaxVideoInput.ts' + +function asContent(blocks: unknown[]): BetaContentBlockParam[] { + return blocks as BetaContentBlockParam[] +} + +test('translates a video URL for the global Anthropic-compatible route', () => { + const content = asContent([ + { type: 'text', text: 'Describe this video.' }, + { + type: 'video_url', + video_url: { url: 'https://example.com/demo.mp4' }, + }, + ]) + + assert.deepEqual( + normalizeMiniMaxVideoContent( + content, + 'https://api.minimax.io/anthropic/', + ), + [ + { type: 'text', text: 'Describe this video.' }, + { + type: 'video', + source: { type: 'url', url: 'https://example.com/demo.mp4' }, + }, + ], + ) +}) + +test('preserves video sampling options for the China route', () => { + const content = asContent([ + { + type: 'video_url', + video_url: { + url: 'mm_file://video-file', + detail: 'high', + fps: 2, + max_long_side_pixel: 1080, + }, + }, + ]) + + assert.deepEqual( + normalizeMiniMaxVideoContent( + content, + 'https://api.minimaxi.com/anthropic', + ), + [ + { + type: 'video', + source: { + type: 'url', + url: 'mm_file://video-file', + detail: 'high', + fps: 2, + max_long_side_pixel: 1080, + }, + }, + ], + ) +}) + +test('leaves content unchanged for other routes', () => { + const content = asContent([ + { + type: 'video_url', + video_url: { url: 'https://example.com/demo.mp4' }, + }, + ]) + + assert.strictEqual( + normalizeMiniMaxVideoContent(content, 'https://example.com/anthropic'), + content, + ) +}) diff --git a/restored-src/src/services/api/minimaxVideoInput.ts b/restored-src/src/services/api/minimaxVideoInput.ts new file mode 100644 index 000000000..d30514750 --- /dev/null +++ b/restored-src/src/services/api/minimaxVideoInput.ts @@ -0,0 +1,64 @@ +import type { BetaContentBlockParam } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' + +type MiniMaxVideoURLBlock = { + type: 'video_url' + video_url: { + url: string + detail?: 'low' | 'default' | 'high' + fps?: number + max_long_side_pixel?: number + } +} + +const MINIMAX_ANTHROPIC_BASE_URLS = new Set([ + 'https://api.minimax.io/anthropic', + 'https://api.minimaxi.com/anthropic', +]) + +function isMiniMaxAnthropicBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false + + try { + const url = new URL(baseUrl) + const normalizedPath = url.pathname.replace(/\/+$/, '') + return MINIMAX_ANTHROPIC_BASE_URLS.has(`${url.origin}${normalizedPath}`) + } catch { + return false + } +} + +export function normalizeMiniMaxVideoContent( + content: string | BetaContentBlockParam[], + baseUrl = process.env.ANTHROPIC_BASE_URL, +): string | BetaContentBlockParam[] { + if (typeof content === 'string' || !isMiniMaxAnthropicBaseUrl(baseUrl)) { + return content + } + + let changed = false + const normalized = content.map(block => { + const candidate = block as unknown as MiniMaxVideoURLBlock + if ( + candidate.type !== 'video_url' || + typeof candidate.video_url?.url !== 'string' || + candidate.video_url.url.length === 0 + ) { + return block + } + + changed = true + const { url, detail, fps, max_long_side_pixel } = candidate.video_url + return { + type: 'video', + source: { + type: 'url', + url, + ...(detail !== undefined && { detail }), + ...(fps !== undefined && { fps }), + ...(max_long_side_pixel !== undefined && { max_long_side_pixel }), + }, + } as unknown as BetaContentBlockParam + }) + + return changed ? normalized : content +}