kimi-vercel-ai-sdk-provider 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +198 -0
  2. package/README.md +871 -0
  3. package/dist/index.d.mts +1317 -0
  4. package/dist/index.d.ts +1317 -0
  5. package/dist/index.js +2764 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +2734 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +70 -0
  10. package/src/__tests__/caching.test.ts +97 -0
  11. package/src/__tests__/chat.test.ts +386 -0
  12. package/src/__tests__/code-integration.test.ts +562 -0
  13. package/src/__tests__/code-provider.test.ts +289 -0
  14. package/src/__tests__/code.test.ts +427 -0
  15. package/src/__tests__/core.test.ts +172 -0
  16. package/src/__tests__/files.test.ts +185 -0
  17. package/src/__tests__/integration.test.ts +457 -0
  18. package/src/__tests__/provider.test.ts +188 -0
  19. package/src/__tests__/tools.test.ts +519 -0
  20. package/src/chat/index.ts +42 -0
  21. package/src/chat/kimi-chat-language-model.ts +829 -0
  22. package/src/chat/kimi-chat-messages.ts +297 -0
  23. package/src/chat/kimi-chat-response.ts +84 -0
  24. package/src/chat/kimi-chat-settings.ts +216 -0
  25. package/src/code/index.ts +66 -0
  26. package/src/code/kimi-code-language-model.ts +669 -0
  27. package/src/code/kimi-code-messages.ts +303 -0
  28. package/src/code/kimi-code-provider.ts +239 -0
  29. package/src/code/kimi-code-settings.ts +193 -0
  30. package/src/code/kimi-code-types.ts +354 -0
  31. package/src/core/errors.ts +140 -0
  32. package/src/core/index.ts +36 -0
  33. package/src/core/types.ts +148 -0
  34. package/src/core/utils.ts +210 -0
  35. package/src/files/attachment-processor.ts +276 -0
  36. package/src/files/file-utils.ts +257 -0
  37. package/src/files/index.ts +24 -0
  38. package/src/files/kimi-file-client.ts +292 -0
  39. package/src/index.ts +122 -0
  40. package/src/kimi-provider.ts +263 -0
  41. package/src/tools/builtin-tools.ts +273 -0
  42. package/src/tools/index.ts +33 -0
  43. package/src/tools/prepare-tools.ts +306 -0
  44. package/src/version.ts +4 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Core module exports.
3
+ * @module
4
+ */
5
+
6
+ export type { KimiErrorData } from './errors';
7
+ // Types
8
+ export type {
9
+ KimiChatConfig,
10
+ KimiChatModelId,
11
+ KimiModelCapabilities,
12
+ KimiResponseMetadata,
13
+ KimiTokenUsage
14
+ } from './types';
15
+ // Utilities
16
+ export type { KimiExtendedUsage } from './utils';
17
+ // Errors
18
+ export {
19
+ KimiAuthenticationError,
20
+ KimiContentFilterError,
21
+ KimiContextLengthError,
22
+ KimiError,
23
+ KimiModelNotFoundError,
24
+ KimiRateLimitError,
25
+ KimiValidationError,
26
+ kimiErrorSchema,
27
+ kimiFailedResponseHandler
28
+ } from './errors';
29
+ export { inferModelCapabilities } from './types';
30
+ export {
31
+ convertKimiUsage,
32
+ extractMessageContent,
33
+ getKimiRequestId,
34
+ getResponseMetadata,
35
+ mapKimiFinishReason
36
+ } from './utils';
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Core types for the Kimi provider.
3
+ * @module
4
+ */
5
+
6
+ import type { LanguageModelV3 } from '@ai-sdk/provider';
7
+
8
+ // ============================================================================
9
+ // Model IDs
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Available Kimi chat model IDs.
14
+ *
15
+ * @remarks
16
+ * - `kimi-k2.5` - Latest flagship model with multimodal support
17
+ * - `kimi-k2.5-thinking` - K2.5 with always-on deep reasoning
18
+ * - `kimi-k2-turbo` - Fast, cost-effective model
19
+ * - `kimi-k2-thinking` - K2 with always-on deep reasoning
20
+ */
21
+ export type KimiChatModelId = 'kimi-k2.5' | 'kimi-k2.5-thinking' | 'kimi-k2-turbo' | 'kimi-k2-thinking' | (string & {});
22
+
23
+ // ============================================================================
24
+ // Model Capabilities
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Capabilities that can be detected from model ID patterns or explicitly set.
29
+ */
30
+ export interface KimiModelCapabilities {
31
+ /**
32
+ * Whether the model supports thinking/reasoning mode.
33
+ * Models with `-thinking` suffix have this enabled by default.
34
+ */
35
+ thinking?: boolean;
36
+
37
+ /**
38
+ * Whether the model always uses thinking mode (cannot be disabled).
39
+ * Thinking models like `kimi-k2.5-thinking` have this set to true.
40
+ */
41
+ alwaysThinking?: boolean;
42
+
43
+ /**
44
+ * Whether the model supports image inputs.
45
+ */
46
+ imageInput?: boolean;
47
+
48
+ /**
49
+ * Whether the model supports video inputs.
50
+ * Currently only kimi-k2.5 models support video.
51
+ */
52
+ videoInput?: boolean;
53
+
54
+ /**
55
+ * Maximum context window size in tokens.
56
+ */
57
+ maxContextSize?: number;
58
+
59
+ /**
60
+ * Whether the model supports tool/function calling.
61
+ */
62
+ toolCalling?: boolean;
63
+
64
+ /**
65
+ * Whether the model supports JSON mode.
66
+ */
67
+ jsonMode?: boolean;
68
+
69
+ /**
70
+ * Whether the model supports structured outputs.
71
+ */
72
+ structuredOutputs?: boolean;
73
+ }
74
+
75
+ /**
76
+ * Infer model capabilities from the model ID.
77
+ *
78
+ * @param modelId - The model identifier
79
+ * @returns Inferred capabilities based on model name patterns
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const caps = inferModelCapabilities('kimi-k2.5-thinking');
84
+ * // { thinking: true, alwaysThinking: true, videoInput: true, ... }
85
+ * ```
86
+ */
87
+ export function inferModelCapabilities(modelId: string): KimiModelCapabilities {
88
+ const isThinkingModel = modelId.includes('-thinking');
89
+ const isK25Model = modelId.includes('k2.5') || modelId.includes('k2-5');
90
+
91
+ return {
92
+ thinking: isThinkingModel,
93
+ alwaysThinking: isThinkingModel,
94
+ imageInput: true, // All Kimi models support images
95
+ videoInput: isK25Model, // Only K2.5 models support video
96
+ maxContextSize: 256_000, // 256k context window
97
+ toolCalling: true,
98
+ jsonMode: true,
99
+ structuredOutputs: true
100
+ };
101
+ }
102
+
103
+ // ============================================================================
104
+ // Provider Configuration
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Configuration for the chat language model.
109
+ * @internal
110
+ */
111
+ export interface KimiChatConfig {
112
+ provider: string;
113
+ baseURL: string;
114
+ headers: () => Record<string, string | undefined>;
115
+ fetch?: typeof globalThis.fetch;
116
+ generateId?: () => string;
117
+ supportsStructuredOutputs?: boolean;
118
+ includeUsageInStream?: boolean;
119
+ supportedUrls?: LanguageModelV3['supportedUrls'];
120
+ }
121
+
122
+ // ============================================================================
123
+ // API Response Types
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Token usage information from Kimi API.
128
+ */
129
+ export interface KimiTokenUsage {
130
+ prompt_tokens?: number | null;
131
+ completion_tokens?: number | null;
132
+ total_tokens?: number | null;
133
+ prompt_tokens_details?: {
134
+ cached_tokens?: number | null;
135
+ } | null;
136
+ completion_tokens_details?: {
137
+ reasoning_tokens?: number | null;
138
+ } | null;
139
+ }
140
+
141
+ /**
142
+ * Response metadata from Kimi API.
143
+ */
144
+ export interface KimiResponseMetadata {
145
+ id?: string | null;
146
+ model?: string | null;
147
+ created?: number | null;
148
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Utility functions for the Kimi provider.
3
+ * @module
4
+ */
5
+
6
+ import type { JSONObject, LanguageModelV3FinishReason, LanguageModelV3Usage } from '@ai-sdk/provider';
7
+ import type { KimiResponseMetadata, KimiTokenUsage } from './types';
8
+
9
+ // ============================================================================
10
+ // Finish Reason Mapping
11
+ // ============================================================================
12
+
13
+ /**
14
+ * Map Kimi finish reasons to standard AI SDK finish reasons.
15
+ *
16
+ * @param finishReason - The raw finish reason from Kimi API
17
+ * @returns The mapped unified finish reason
18
+ */
19
+ export function mapKimiFinishReason(finishReason: string | null | undefined): LanguageModelV3FinishReason['unified'] {
20
+ switch (finishReason) {
21
+ case 'stop':
22
+ return 'stop';
23
+ case 'length':
24
+ return 'length';
25
+ case 'content_filter':
26
+ return 'content-filter';
27
+ case 'tool_calls':
28
+ case 'function_call':
29
+ return 'tool-calls';
30
+ default:
31
+ return 'other';
32
+ }
33
+ }
34
+
35
+ // ============================================================================
36
+ // Usage Conversion
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Extended usage information including web search and code interpreter tokens.
41
+ */
42
+ export interface KimiExtendedUsage extends LanguageModelV3Usage {
43
+ /**
44
+ * Tokens used by the built-in web search tool.
45
+ */
46
+ webSearchTokens?: number;
47
+
48
+ /**
49
+ * Tokens used by the built-in code interpreter.
50
+ */
51
+ codeInterpreterTokens?: number;
52
+ }
53
+
54
+ /**
55
+ * Convert Kimi usage data to standard AI SDK usage format.
56
+ *
57
+ * @param usage - The raw usage data from Kimi API
58
+ * @param webSearchTokens - Optional web search token count
59
+ * @param codeInterpreterTokens - Optional code interpreter token count
60
+ * @returns Standardized usage object
61
+ */
62
+ export function convertKimiUsage(
63
+ usage: KimiTokenUsage | null | undefined,
64
+ webSearchTokens?: number,
65
+ codeInterpreterTokens?: number
66
+ ): KimiExtendedUsage {
67
+ if (usage == null) {
68
+ return {
69
+ inputTokens: {
70
+ total: undefined,
71
+ noCache: undefined,
72
+ cacheRead: undefined,
73
+ cacheWrite: undefined
74
+ },
75
+ outputTokens: {
76
+ total: undefined,
77
+ text: undefined,
78
+ reasoning: undefined
79
+ },
80
+ raw: undefined,
81
+ ...(webSearchTokens != null ? { webSearchTokens } : {}),
82
+ ...(codeInterpreterTokens != null ? { codeInterpreterTokens } : {})
83
+ };
84
+ }
85
+
86
+ const promptTokens = usage.prompt_tokens ?? 0;
87
+ const completionTokens = usage.completion_tokens ?? 0;
88
+ const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
89
+ const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0;
90
+
91
+ // Convert to JSONObject for the raw field
92
+ const rawUsage: JSONObject = {
93
+ prompt_tokens: usage.prompt_tokens ?? undefined,
94
+ completion_tokens: usage.completion_tokens ?? undefined,
95
+ total_tokens: usage.total_tokens ?? undefined,
96
+ ...(usage.prompt_tokens_details
97
+ ? {
98
+ prompt_tokens_details: {
99
+ cached_tokens: usage.prompt_tokens_details.cached_tokens ?? undefined
100
+ }
101
+ }
102
+ : {}),
103
+ ...(usage.completion_tokens_details
104
+ ? {
105
+ completion_tokens_details: {
106
+ reasoning_tokens: usage.completion_tokens_details.reasoning_tokens ?? undefined
107
+ }
108
+ }
109
+ : {})
110
+ };
111
+
112
+ return {
113
+ inputTokens: {
114
+ total: promptTokens,
115
+ noCache: promptTokens - cacheReadTokens,
116
+ cacheRead: cacheReadTokens,
117
+ cacheWrite: undefined
118
+ },
119
+ outputTokens: {
120
+ total: completionTokens,
121
+ text: completionTokens - reasoningTokens,
122
+ reasoning: reasoningTokens
123
+ },
124
+ raw: rawUsage,
125
+ ...(webSearchTokens != null ? { webSearchTokens } : {}),
126
+ ...(codeInterpreterTokens != null ? { codeInterpreterTokens } : {})
127
+ };
128
+ }
129
+
130
+ // ============================================================================
131
+ // Response Metadata
132
+ // ============================================================================
133
+
134
+ /**
135
+ * Extract response metadata from Kimi API response.
136
+ *
137
+ * @param response - The raw response metadata
138
+ * @returns Formatted response metadata
139
+ */
140
+ export function getResponseMetadata(response: KimiResponseMetadata) {
141
+ return {
142
+ id: response.id ?? undefined,
143
+ modelId: response.model ?? undefined,
144
+ timestamp: response.created != null ? new Date(response.created * 1000) : undefined
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Extract request ID from response headers.
150
+ *
151
+ * @param headers - Response headers
152
+ * @returns The request ID if found
153
+ */
154
+ export function getKimiRequestId(headers?: Record<string, string>): string | undefined {
155
+ if (!headers) {
156
+ return undefined;
157
+ }
158
+
159
+ const lowerHeaders = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
160
+
161
+ return lowerHeaders['x-request-id'] || lowerHeaders['x-trace-id'] || lowerHeaders['x-moonshot-request-id'];
162
+ }
163
+
164
+ // ============================================================================
165
+ // Message Content Extraction
166
+ // ============================================================================
167
+
168
+ /**
169
+ * Extract text and reasoning content from a message.
170
+ *
171
+ * @param message - The message object from API response
172
+ * @returns Extracted text and reasoning content
173
+ */
174
+ export function extractMessageContent(message: {
175
+ content?: unknown;
176
+ reasoning_content?: string | null;
177
+ reasoning?: string | null;
178
+ }): { text: string; reasoning: string } {
179
+ let text = '';
180
+ let reasoning = '';
181
+
182
+ if (typeof message.content === 'string') {
183
+ text = message.content;
184
+ } else if (Array.isArray(message.content)) {
185
+ for (const part of message.content) {
186
+ if (part && typeof part === 'object') {
187
+ const candidate = part as Record<string, unknown>;
188
+ if (candidate.type === 'text' && typeof candidate.text === 'string') {
189
+ text += candidate.text;
190
+ }
191
+ if (candidate.type === 'thinking' && typeof candidate.thinking === 'string') {
192
+ reasoning += candidate.thinking;
193
+ }
194
+ if (candidate.type === 'reasoning' && typeof candidate.text === 'string') {
195
+ reasoning += candidate.text;
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ if (typeof message.reasoning_content === 'string') {
202
+ reasoning += message.reasoning_content;
203
+ }
204
+
205
+ if (typeof message.reasoning === 'string') {
206
+ reasoning += message.reasoning;
207
+ }
208
+
209
+ return { text, reasoning };
210
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Attachment processor for experimental_attachments support.
3
+ * Automatically uploads files to Kimi and injects content into prompts.
4
+ * @module
5
+ */
6
+
7
+ import {
8
+ getExtensionFromPath,
9
+ getMediaTypeFromExtension,
10
+ isDocumentMediaType,
11
+ isFileExtractMediaType,
12
+ isImageMediaType,
13
+ isVideoMediaType
14
+ } from './file-utils';
15
+ import { KimiFileClient, type KimiFileClientConfig } from './kimi-file-client';
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /**
22
+ * An attachment from experimental_attachments.
23
+ */
24
+ export interface Attachment {
25
+ /** URL of the attachment */
26
+ url?: string;
27
+ /** Name of the attachment */
28
+ name?: string;
29
+ /** MIME type */
30
+ contentType?: string;
31
+ /** Raw content data */
32
+ content?: Uint8Array | string;
33
+ }
34
+
35
+ /**
36
+ * Processed attachment result.
37
+ */
38
+ export interface ProcessedAttachment {
39
+ /** Original attachment */
40
+ original: Attachment;
41
+ /** Processing type */
42
+ type: 'text-inject' | 'image-url' | 'video-url' | 'skip';
43
+ /** Extracted text content (for documents) */
44
+ textContent?: string;
45
+ /** URL to use in message (for images/videos) */
46
+ mediaUrl?: string;
47
+ /** Kimi file ID (if uploaded) */
48
+ fileId?: string;
49
+ /** Error if processing failed */
50
+ error?: string;
51
+ }
52
+
53
+ /**
54
+ * Options for processing attachments.
55
+ */
56
+ export interface ProcessAttachmentsOptions {
57
+ /** Attachments to process */
58
+ attachments: Attachment[];
59
+ /** File client configuration */
60
+ clientConfig: KimiFileClientConfig;
61
+ /** Whether to auto-upload documents for extraction */
62
+ autoUploadDocuments?: boolean;
63
+ /** Whether to upload images to Kimi's file API */
64
+ uploadImages?: boolean;
65
+ /** Whether to delete files after extraction (cleanup) */
66
+ cleanupAfterExtract?: boolean;
67
+ }
68
+
69
+ // ============================================================================
70
+ // Main Function
71
+ // ============================================================================
72
+
73
+ /**
74
+ * Process experimental_attachments for Kimi.
75
+ *
76
+ * This function handles different attachment types:
77
+ * - Documents (PDF, DOC, etc.): Uploads to Kimi, extracts content, returns text to inject
78
+ * - Images: Returns URL for vision input
79
+ * - Videos: Returns URL for video input
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const processed = await processAttachments({
84
+ * attachments: message.experimental_attachments ?? [],
85
+ * clientConfig: {
86
+ * baseURL: 'https://api.moonshot.ai/v1',
87
+ * headers: () => ({ Authorization: `Bearer ${apiKey}` }),
88
+ * },
89
+ * });
90
+ *
91
+ * // Inject document content into system messages
92
+ * const documentContent = processed
93
+ * .filter(p => p.type === 'text-inject' && p.textContent)
94
+ * .map(p => p.textContent)
95
+ * .join('\n');
96
+ * ```
97
+ */
98
+ export async function processAttachments(options: ProcessAttachmentsOptions): Promise<ProcessedAttachment[]> {
99
+ const {
100
+ attachments,
101
+ clientConfig,
102
+ autoUploadDocuments = true,
103
+ uploadImages = false,
104
+ cleanupAfterExtract = false
105
+ } = options;
106
+
107
+ const results: ProcessedAttachment[] = [];
108
+ const client = new KimiFileClient(clientConfig);
109
+
110
+ for (const attachment of attachments) {
111
+ try {
112
+ const processed = await processAttachment(attachment, client, {
113
+ autoUploadDocuments,
114
+ uploadImages,
115
+ cleanupAfterExtract
116
+ });
117
+ results.push(processed);
118
+ } catch (error) {
119
+ results.push({
120
+ original: attachment,
121
+ type: 'skip',
122
+ error: error instanceof Error ? error.message : String(error)
123
+ });
124
+ }
125
+ }
126
+
127
+ return results;
128
+ }
129
+
130
+ // ============================================================================
131
+ // Helper Functions
132
+ // ============================================================================
133
+
134
+ async function processAttachment(
135
+ attachment: Attachment,
136
+ client: KimiFileClient,
137
+ options: { autoUploadDocuments: boolean; uploadImages: boolean; cleanupAfterExtract: boolean }
138
+ ): Promise<ProcessedAttachment> {
139
+ // Determine content type
140
+ const contentType = resolveContentType(attachment);
141
+
142
+ // Handle images - just return URL for vision
143
+ if (isImageMediaType(contentType)) {
144
+ if (options.uploadImages && attachment.content) {
145
+ // Upload image if content is provided
146
+ const result = await client.upload({
147
+ data: attachment.content,
148
+ filename: attachment.name ?? 'image.jpg',
149
+ mediaType: contentType,
150
+ purpose: 'image'
151
+ });
152
+
153
+ return {
154
+ original: attachment,
155
+ type: 'image-url',
156
+ fileId: result.id,
157
+ mediaUrl: attachment.url
158
+ };
159
+ }
160
+
161
+ return {
162
+ original: attachment,
163
+ type: 'image-url',
164
+ mediaUrl: attachment.url
165
+ };
166
+ }
167
+
168
+ // Handle videos - just return URL for video understanding
169
+ if (isVideoMediaType(contentType)) {
170
+ return {
171
+ original: attachment,
172
+ type: 'video-url',
173
+ mediaUrl: attachment.url
174
+ };
175
+ }
176
+
177
+ // Handle documents that need extraction
178
+ if (options.autoUploadDocuments && (isDocumentMediaType(contentType) || isFileExtractMediaType(contentType))) {
179
+ // Need to fetch content if only URL is provided
180
+ let data: Uint8Array | string;
181
+
182
+ if (attachment.content) {
183
+ data = attachment.content;
184
+ } else if (attachment.url) {
185
+ // Fetch the file from URL
186
+ const response = await fetch(attachment.url);
187
+ if (!response.ok) {
188
+ throw new Error(`Failed to fetch attachment: ${response.status}`);
189
+ }
190
+ data = new Uint8Array(await response.arrayBuffer());
191
+ } else {
192
+ return {
193
+ original: attachment,
194
+ type: 'skip',
195
+ error: 'No content or URL provided for document attachment'
196
+ };
197
+ }
198
+
199
+ // Upload and extract content
200
+ const result = await client.uploadAndExtract({
201
+ data,
202
+ filename: attachment.name ?? guessFilename(attachment, contentType),
203
+ mediaType: contentType,
204
+ purpose: 'file-extract'
205
+ });
206
+
207
+ // Cleanup if requested
208
+ if (options.cleanupAfterExtract && result.file.id) {
209
+ try {
210
+ await client.deleteFile(result.file.id);
211
+ } catch {
212
+ // Ignore cleanup errors
213
+ }
214
+ }
215
+
216
+ return {
217
+ original: attachment,
218
+ type: 'text-inject',
219
+ textContent: result.content,
220
+ fileId: result.file.id
221
+ };
222
+ }
223
+
224
+ // Skip unsupported types
225
+ return {
226
+ original: attachment,
227
+ type: 'skip',
228
+ error: `Unsupported content type: ${contentType}`
229
+ };
230
+ }
231
+
232
+ function resolveContentType(attachment: Attachment): string {
233
+ // Use explicit content type if provided
234
+ if (attachment.contentType) {
235
+ return attachment.contentType;
236
+ }
237
+
238
+ // Try to infer from filename or URL
239
+ const path = attachment.name ?? attachment.url;
240
+ if (path) {
241
+ const ext = getExtensionFromPath(path);
242
+ if (ext) {
243
+ return getMediaTypeFromExtension(ext);
244
+ }
245
+ }
246
+
247
+ // Default to octet-stream
248
+ return 'application/octet-stream';
249
+ }
250
+
251
+ function guessFilename(attachment: Attachment, contentType: string): string {
252
+ if (attachment.name) {
253
+ return attachment.name;
254
+ }
255
+
256
+ if (attachment.url) {
257
+ const urlPath = attachment.url.split('?')[0];
258
+ const segments = urlPath.split('/');
259
+ const lastSegment = segments[segments.length - 1];
260
+ if (lastSegment && lastSegment.includes('.')) {
261
+ return lastSegment;
262
+ }
263
+ }
264
+
265
+ // Generate filename from content type
266
+ const extensionMap: Record<string, string> = {
267
+ 'application/pdf': 'document.pdf',
268
+ 'text/plain': 'document.txt',
269
+ 'application/msword': 'document.doc',
270
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document.docx',
271
+ 'image/jpeg': 'image.jpg',
272
+ 'image/png': 'image.png'
273
+ };
274
+
275
+ return extensionMap[contentType] ?? 'file.bin';
276
+ }