iris-chatbot 5.3.0 → 5.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "5.3.0",
9
+ "version": "5.4.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -1,6 +1,8 @@
1
1
  import OpenAI from "openai";
2
2
  import Anthropic from "@anthropic-ai/sdk";
3
+ import type { BetaContentBlockParam, BetaMessageParam } from "@anthropic-ai/sdk/resources/beta";
3
4
  import type {
5
+ ChatAttachment,
4
6
  ChatConnectionPayload,
5
7
  ChatMessageInput,
6
8
  MemoryContextPayload,
@@ -11,6 +13,10 @@ import type {
11
13
  } from "../../../lib/types";
12
14
  import { DEFAULT_LOCAL_TOOLS_SETTINGS } from "../../../lib/types";
13
15
  import { GEMINI_OPENAI_BASE_URL, supportsToolsByDefault } from "../../../lib/connections";
16
+ import {
17
+ getAttachmentProviderForConnection,
18
+ normalizeChatAttachments,
19
+ } from "../../../lib/attachments";
14
20
  import { createApprovalRequest } from "../../../lib/tooling/approvals";
15
21
  import {
16
22
  createToolRegistry,
@@ -514,17 +520,59 @@ function sanitizeChatMessages(messages: ChatMessageInput[]): ChatMessageInput[]
514
520
  continue;
515
521
  }
516
522
  const normalizedContent = message.content.replace(/\r\n/g, "\n");
517
- if (!normalizedContent.trim()) {
523
+ const normalizedAttachments =
524
+ role === "user" ? normalizeChatAttachments(message.attachments) : [];
525
+ if (!normalizedContent.trim() && normalizedAttachments.length === 0) {
518
526
  continue;
519
527
  }
520
528
  sanitized.push({
521
529
  role,
522
530
  content: normalizedContent,
531
+ ...(normalizedAttachments.length > 0
532
+ ? { attachments: normalizedAttachments }
533
+ : {}),
523
534
  });
524
535
  }
525
536
  return sanitized;
526
537
  }
527
538
 
539
+ function messageHasAttachments(message: ChatMessageInput): boolean {
540
+ return Array.isArray(message.attachments) && message.attachments.length > 0;
541
+ }
542
+
543
+ function messagesContainAttachments(messages: ChatMessageInput[]): boolean {
544
+ return messages.some((message) => messageHasAttachments(message));
545
+ }
546
+
547
+ function validateAttachmentProviderCompatibility(params: {
548
+ connection: RuntimeConnection;
549
+ messages: ChatMessageInput[];
550
+ }): string | null {
551
+ const providerFromConnection = getAttachmentProviderForConnection(params.connection);
552
+ const providers = new Set<ChatAttachment["provider"]>();
553
+ for (const message of params.messages) {
554
+ for (const attachment of message.attachments ?? []) {
555
+ providers.add(attachment.provider);
556
+ }
557
+ }
558
+
559
+ if (providers.size === 0) {
560
+ return null;
561
+ }
562
+ if (providers.size > 1) {
563
+ return "Attachments from multiple providers cannot be mixed in one request.";
564
+ }
565
+ if (!providerFromConnection) {
566
+ return "This connection does not support attachments.";
567
+ }
568
+
569
+ const providerFromAttachment = [...providers][0];
570
+ if (providerFromAttachment !== providerFromConnection) {
571
+ return `Attachments were uploaded for ${providerFromAttachment}, but the active connection is ${providerFromConnection}.`;
572
+ }
573
+ return null;
574
+ }
575
+
528
576
  function sanitizeMemoryContext(
529
577
  memory: ChatRequest["memory"],
530
578
  ): MemoryContextPayload | null {
@@ -638,10 +686,103 @@ function normalizeWebSourcesEnabled(value: boolean | undefined): boolean {
638
686
  return value !== false;
639
687
  }
640
688
 
689
+ type WebSearchMode = "off" | "auto" | "required";
690
+
641
691
  function supportsWebSourcesViaOpenAI(connection: RuntimeConnection): boolean {
642
692
  return connection.kind === "builtin" && connection.provider === "openai";
643
693
  }
644
694
 
695
+ function getLastUserMessageText(messages: ChatMessageInput[]): string {
696
+ return [...messages].reverse().find((message) => message.role === "user")?.content?.trim() ?? "";
697
+ }
698
+
699
+ function decideWebSearchMode(params: {
700
+ enabled: boolean;
701
+ connection: RuntimeConnection;
702
+ messages: ChatMessageInput[];
703
+ }): WebSearchMode {
704
+ if (!params.enabled || !supportsWebSourcesViaOpenAI(params.connection)) {
705
+ return "off";
706
+ }
707
+
708
+ const text = getLastUserMessageText(params.messages).replace(/\s+/g, " ").trim().toLowerCase();
709
+ if (!text) {
710
+ return "auto";
711
+ }
712
+
713
+ const disableSearchRequested =
714
+ /\b(?:no|without|skip)\s+(?:web\s+)?(?:search|browsing|browse|lookup|look up)\b/.test(text) ||
715
+ /\b(?:don't|do not)\s+(?:web\s+)?(?:search|browse|look up|lookup)\b/.test(text) ||
716
+ /\boffline only\b/.test(text);
717
+ if (disableSearchRequested) {
718
+ return "off";
719
+ }
720
+
721
+ const explicitSearchRequested =
722
+ /\bsearch(?:\s+the)?\s+web\b/.test(text) ||
723
+ /\blook(?:\s+it)?\s+up\b/.test(text) ||
724
+ /\bcheck(?:\s+it)?\s+online\b/.test(text) ||
725
+ /\bverify(?:\s+it)?\s+online\b/.test(text) ||
726
+ /\bbrowse(?:\s+the)?\s+web\b/.test(text) ||
727
+ /\bgoogle\s+(?:it|this)\b/.test(text);
728
+ if (explicitSearchRequested) {
729
+ return "required";
730
+ }
731
+
732
+ const asksForSources =
733
+ /\b(?:source|sources|cite|citation|citations|reference|references|link|links)\b/.test(text);
734
+ const freshnessSignals =
735
+ /\b(?:latest|current|currently|today|right now|as of|recent|recently|up[- ]to[- ]date|breaking)\b/.test(
736
+ text,
737
+ );
738
+ const volatileDomainSignals =
739
+ /\b(?:price|pricing|cost|fee|subscription|per token|rate limit|stock|share price|market cap|forecast|weather|score|standings|odds|election|poll)\b/.test(
740
+ text,
741
+ );
742
+ const leadershipSignals =
743
+ /\b(?:who is|current)\s+(?:the\s+)?(?:ceo|president|prime minister|governor|mayor)\b/.test(text);
744
+ const metricSignals =
745
+ /\b(?:average|median|how many|number of|count|rate|percentage|percent|statistics?|stats?)\b/.test(
746
+ text,
747
+ );
748
+ const metricEntitySignals =
749
+ /\b(?:users?|people|prompts?|requests?|queries?|api|model|gpt|traffic|revenue|downloads?|adoption|daily|weekly|monthly|yearly|per day|per week|per month)\b/.test(
750
+ text,
751
+ );
752
+
753
+ if (
754
+ asksForSources ||
755
+ freshnessSignals ||
756
+ volatileDomainSignals ||
757
+ leadershipSignals ||
758
+ (metricSignals && metricEntitySignals)
759
+ ) {
760
+ return "required";
761
+ }
762
+
763
+ const likelyFactualQuestion =
764
+ text.includes("?") || /^(?:what|who|when|where|which|how)\b/.test(text);
765
+ const uncertaintySignals =
766
+ /\b(?:not sure|unsure|uncertain|verify|double-check|confirm|accurate|accuracy)\b/.test(text);
767
+ if (likelyFactualQuestion && uncertaintySignals) {
768
+ return "auto";
769
+ }
770
+
771
+ return "off";
772
+ }
773
+
774
+ function withWebSearchGuidance(system: string | undefined, mode: WebSearchMode): string | undefined {
775
+ const trimmed = system?.trim() || "";
776
+ if (mode === "off") {
777
+ return trimmed || undefined;
778
+ }
779
+ const guidance =
780
+ mode === "required"
781
+ ? "Web search is required for this turn. Use web_search before answering and cite the sources you relied on."
782
+ : "Use web_search when the request depends on current or uncertain facts (pricing, statistics, recency). For stable/general knowledge, answer directly without web_search.";
783
+ return trimmed ? `${trimmed}\n\n${guidance}` : guidance;
784
+ }
785
+
645
786
  function mergeCitationSources(
646
787
  target: Map<string, ProviderCitationSource>,
647
788
  sources: ProviderCitationSource[] | undefined,
@@ -2481,12 +2622,97 @@ async function tryDirectAutomationFastPath(params: {
2481
2622
  return true;
2482
2623
  }
2483
2624
 
2625
+ function toOpenAIMessageContent(
2626
+ message: ChatMessageInput,
2627
+ ): string | OpenAI.Responses.ResponseInputMessageContentList {
2628
+ if (message.role !== "user" || !messageHasAttachments(message)) {
2629
+ return message.content;
2630
+ }
2631
+
2632
+ const content: OpenAI.Responses.ResponseInputMessageContentList = [];
2633
+ if (message.content.trim()) {
2634
+ content.push({
2635
+ type: "input_text",
2636
+ text: message.content,
2637
+ });
2638
+ }
2639
+
2640
+ for (const attachment of message.attachments ?? []) {
2641
+ if (attachment.kind === "image") {
2642
+ content.push({
2643
+ type: "input_image",
2644
+ detail: "auto",
2645
+ file_id: attachment.providerFileId,
2646
+ });
2647
+ continue;
2648
+ }
2649
+ content.push({
2650
+ type: "input_file",
2651
+ file_id: attachment.providerFileId,
2652
+ filename: attachment.name,
2653
+ });
2654
+ }
2655
+
2656
+ if (content.length === 0) {
2657
+ content.push({
2658
+ type: "input_text",
2659
+ text: "",
2660
+ });
2661
+ }
2662
+ return content;
2663
+ }
2664
+
2665
+ function toAnthropicUserContent(message: ChatMessageInput): string | BetaContentBlockParam[] {
2666
+ if (!messageHasAttachments(message)) {
2667
+ return message.content;
2668
+ }
2669
+
2670
+ const content: BetaContentBlockParam[] = [];
2671
+ if (message.content.trim()) {
2672
+ content.push({
2673
+ type: "text",
2674
+ text: message.content,
2675
+ });
2676
+ }
2677
+
2678
+ for (const attachment of message.attachments ?? []) {
2679
+ if (attachment.kind === "image") {
2680
+ content.push({
2681
+ type: "image",
2682
+ source: {
2683
+ type: "file",
2684
+ file_id: attachment.providerFileId,
2685
+ },
2686
+ });
2687
+ continue;
2688
+ }
2689
+ content.push({
2690
+ type: "document",
2691
+ title: attachment.name,
2692
+ citations: { enabled: true },
2693
+ source: {
2694
+ type: "file",
2695
+ file_id: attachment.providerFileId,
2696
+ },
2697
+ });
2698
+ }
2699
+
2700
+ if (content.length === 0) {
2701
+ content.push({
2702
+ type: "text",
2703
+ text: "",
2704
+ });
2705
+ }
2706
+ return content;
2707
+ }
2708
+
2484
2709
  async function streamPlainChat(params: {
2485
2710
  connection: RuntimeConnection;
2486
2711
  model: string;
2487
2712
  system?: string;
2488
2713
  messages: ChatMessageInput[];
2489
2714
  enableWebSources: boolean;
2715
+ webSearchMode?: WebSearchMode;
2490
2716
  send: (chunk: ChatStreamChunk) => void;
2491
2717
  signal?: AbortSignal;
2492
2718
  }) {
@@ -2506,19 +2732,65 @@ async function streamPlainChat(params: {
2506
2732
  throw new Error("Missing Anthropic API key for this connection.");
2507
2733
  }
2508
2734
  const client = new Anthropic({ apiKey: params.connection.apiKey });
2509
- const response = await client.messages.stream({
2510
- model: params.model,
2511
- max_tokens: 1024,
2512
- system: params.system,
2513
- messages: params.messages.map((message) => ({
2514
- role: message.role === "assistant" ? "assistant" : "user",
2515
- content: message.content,
2516
- })),
2517
- });
2735
+ if (messagesContainAttachments(params.messages)) {
2736
+ const systemBlocks = [
2737
+ params.system?.trim() || "",
2738
+ ...params.messages
2739
+ .filter((message) => message.role === "system")
2740
+ .map((message) => message.content.trim())
2741
+ .filter(Boolean),
2742
+ ].filter(Boolean);
2743
+ const anthropicMessages: BetaMessageParam[] = params.messages
2744
+ .filter((message) => message.role !== "system")
2745
+ .map((message) => ({
2746
+ role: message.role === "assistant" ? "assistant" : "user",
2747
+ content:
2748
+ message.role === "assistant"
2749
+ ? message.content
2750
+ : toAnthropicUserContent(message),
2751
+ }));
2752
+
2753
+ const response = await client.beta.messages.create({
2754
+ model: params.model,
2755
+ max_tokens: 1024,
2756
+ system: systemBlocks.length > 0 ? systemBlocks.join("\n\n") : undefined,
2757
+ messages: anthropicMessages,
2758
+ stream: true,
2759
+ betas: ["files-api-2025-04-14", "pdfs-2024-09-25"],
2760
+ });
2761
+
2762
+ for await (const event of response) {
2763
+ const eventAny = event as {
2764
+ type?: string;
2765
+ delta?: { type?: string; text?: string };
2766
+ error?: { message?: string };
2767
+ };
2768
+ if (eventAny.type === "content_block_delta" && eventAny.delta?.type === "text_delta") {
2769
+ emitTokenText(params.send, eventAny.delta.text ?? "");
2770
+ continue;
2771
+ }
2772
+ if (eventAny.type === "error") {
2773
+ params.send({
2774
+ type: "error",
2775
+ error: eventAny.error?.message || "Anthropic response error",
2776
+ });
2777
+ }
2778
+ }
2779
+ } else {
2780
+ const response = await client.messages.stream({
2781
+ model: params.model,
2782
+ max_tokens: 1024,
2783
+ system: params.system,
2784
+ messages: params.messages.map((message) => ({
2785
+ role: message.role === "assistant" ? "assistant" : "user",
2786
+ content: message.content,
2787
+ })),
2788
+ });
2518
2789
 
2519
- for await (const event of response) {
2520
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
2521
- emitTokenText(params.send, event.delta.text);
2790
+ for await (const event of response) {
2791
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
2792
+ emitTokenText(params.send, event.delta.text);
2793
+ }
2522
2794
  }
2523
2795
  }
2524
2796
  return;
@@ -2536,20 +2808,30 @@ async function streamPlainChat(params: {
2536
2808
  baseURL: openAIBaseURL,
2537
2809
  defaultHeaders: openAIHeaders,
2538
2810
  });
2539
- const useWebSources =
2540
- params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
2811
+ const useWebSources = params.enableWebSources && supportsWebSourcesViaOpenAI(params.connection);
2812
+ const webSearchMode: WebSearchMode = useWebSources
2813
+ ? params.webSearchMode === "required"
2814
+ ? "required"
2815
+ : "auto"
2816
+ : "off";
2817
+ const instructions = withWebSearchGuidance(params.system, webSearchMode);
2541
2818
  const response = await client.responses.create(
2542
2819
  {
2543
2820
  model: params.model,
2544
- instructions: params.system,
2821
+ instructions,
2545
2822
  input: params.messages.map((message) => ({
2546
2823
  type: "message",
2547
2824
  role: message.role,
2548
- content: message.content,
2825
+ content: toOpenAIMessageContent(message),
2549
2826
  })),
2550
- tools: useWebSources ? [{ type: "web_search" }] : undefined,
2551
- tool_choice: useWebSources ? "auto" : undefined,
2552
- include: useWebSources ? ["web_search_call.action.sources"] : undefined,
2827
+ tools: webSearchMode !== "off" ? [{ type: "web_search" }] : undefined,
2828
+ tool_choice:
2829
+ webSearchMode === "required"
2830
+ ? "required"
2831
+ : webSearchMode === "auto"
2832
+ ? "auto"
2833
+ : undefined,
2834
+ include: webSearchMode !== "off" ? ["web_search_call.action.sources"] : undefined,
2553
2835
  stream: true,
2554
2836
  },
2555
2837
  { signal: params.signal },
@@ -3001,10 +3283,25 @@ export async function POST(request: Request) {
3001
3283
  headers: { "Content-Type": "application/json" },
3002
3284
  });
3003
3285
  }
3286
+ const hasAttachmentsInMessages = messagesContainAttachments(sanitizedMessages);
3287
+ const attachmentCompatibilityError = validateAttachmentProviderCompatibility({
3288
+ connection,
3289
+ messages: sanitizedMessages,
3290
+ });
3291
+ if (attachmentCompatibilityError) {
3292
+ return new Response(JSON.stringify({ error: attachmentCompatibilityError }), {
3293
+ status: 400,
3294
+ headers: { "Content-Type": "application/json" },
3295
+ });
3296
+ }
3004
3297
 
3005
3298
  const webSourcesEnabled = normalizeWebSourcesEnabled(body.enableWebSources);
3006
- const enableWebSourcesForConnection =
3007
- webSourcesEnabled && supportsWebSourcesViaOpenAI(connection);
3299
+ const webSearchMode = decideWebSearchMode({
3300
+ enabled: webSourcesEnabled,
3301
+ connection,
3302
+ messages: sanitizedMessages,
3303
+ });
3304
+ const enableWebSourcesForConnection = webSearchMode !== "off";
3008
3305
  const localToolsRequested = normalizeLocalTools(body.localTools);
3009
3306
  const memoryContext = sanitizeMemoryContext(body.memory);
3010
3307
  const requestMeta = sanitizeChatRequestMeta(body.meta);
@@ -3046,7 +3343,7 @@ export async function POST(request: Request) {
3046
3343
  });
3047
3344
  }
3048
3345
 
3049
- if (localTools.enabled) {
3346
+ if (localTools.enabled && !hasAttachmentsInMessages) {
3050
3347
  const lastUserText =
3051
3348
  [...sanitizedMessages].reverse().find((message) => message.role === "user")?.content ?? "";
3052
3349
  const communicationIntentType = classifyDraftCommunicationIntent(lastUserText);
@@ -3111,6 +3408,7 @@ export async function POST(request: Request) {
3111
3408
  system,
3112
3409
  messages: sanitizedMessages,
3113
3410
  enableWebSources: enableWebSourcesForConnection,
3411
+ webSearchMode,
3114
3412
  send,
3115
3413
  signal: request.signal,
3116
3414
  });
@@ -0,0 +1,220 @@
1
+ import OpenAI from "openai";
2
+ import Anthropic from "@anthropic-ai/sdk";
3
+ import type { ChatAttachment, ChatConnectionPayload } from "../../../../lib/types";
4
+ import {
5
+ getAttachmentProviderForConnection,
6
+ MAX_ATTACHMENTS_PER_MESSAGE,
7
+ validateAttachmentCandidate,
8
+ } from "../../../../lib/attachments";
9
+
10
+ export const runtime = "nodejs";
11
+ export const dynamic = "force-dynamic";
12
+
13
+ type RuntimeConnection = {
14
+ kind: "builtin" | "openai_compatible" | "ollama";
15
+ provider?: "openai" | "anthropic" | "google";
16
+ baseUrl?: string;
17
+ apiKey?: string;
18
+ headers: Array<{ key: string; value: string }>;
19
+ };
20
+
21
+ function headersArrayToRecord(
22
+ headers: RuntimeConnection["headers"],
23
+ ): Record<string, string> | undefined {
24
+ if (!headers.length) {
25
+ return undefined;
26
+ }
27
+ const out: Record<string, string> = {};
28
+ for (const header of headers) {
29
+ const key = header.key.trim();
30
+ if (!key) {
31
+ continue;
32
+ }
33
+ out[key] = header.value;
34
+ }
35
+ return Object.keys(out).length > 0 ? out : undefined;
36
+ }
37
+
38
+ function normalizeConnection(rawConnection: unknown): RuntimeConnection | null {
39
+ if (!rawConnection || typeof rawConnection !== "object") {
40
+ return null;
41
+ }
42
+ const connection = rawConnection as Partial<ChatConnectionPayload>;
43
+ const kind =
44
+ connection.kind === "builtin" ||
45
+ connection.kind === "openai_compatible" ||
46
+ connection.kind === "ollama"
47
+ ? connection.kind
48
+ : null;
49
+ if (!kind) {
50
+ return null;
51
+ }
52
+ const provider =
53
+ kind === "builtin" &&
54
+ (connection.provider === "openai" ||
55
+ connection.provider === "anthropic" ||
56
+ connection.provider === "google")
57
+ ? connection.provider
58
+ : undefined;
59
+ const baseUrl =
60
+ typeof connection.baseUrl === "string" && connection.baseUrl.trim().length > 0
61
+ ? connection.baseUrl.trim()
62
+ : undefined;
63
+ const apiKey =
64
+ typeof connection.apiKey === "string" && connection.apiKey.trim().length > 0
65
+ ? connection.apiKey.trim()
66
+ : undefined;
67
+ const headers = Array.isArray(connection.headers)
68
+ ? connection.headers
69
+ .filter(
70
+ (header): header is { key: string; value: string } =>
71
+ Boolean(header) &&
72
+ typeof header.key === "string" &&
73
+ typeof header.value === "string",
74
+ )
75
+ .map((header) => ({
76
+ key: header.key,
77
+ value: header.value,
78
+ }))
79
+ : [];
80
+
81
+ return {
82
+ kind,
83
+ provider,
84
+ baseUrl,
85
+ apiKey,
86
+ headers,
87
+ };
88
+ }
89
+
90
+ function badRequest(message: string) {
91
+ return new Response(JSON.stringify({ error: message }), {
92
+ status: 400,
93
+ headers: {
94
+ "Content-Type": "application/json",
95
+ },
96
+ });
97
+ }
98
+
99
+ export async function POST(request: Request) {
100
+ let formData: FormData;
101
+ try {
102
+ formData = await request.formData();
103
+ } catch {
104
+ return badRequest("Invalid multipart form payload.");
105
+ }
106
+
107
+ const rawConnection = formData.get("connection");
108
+ if (typeof rawConnection !== "string" || !rawConnection.trim()) {
109
+ return badRequest("Missing connection payload.");
110
+ }
111
+
112
+ let parsedConnectionValue: unknown;
113
+ try {
114
+ parsedConnectionValue = JSON.parse(rawConnection);
115
+ } catch {
116
+ return badRequest("Invalid connection payload.");
117
+ }
118
+
119
+ const connection = normalizeConnection(parsedConnectionValue);
120
+ if (!connection) {
121
+ return badRequest("Invalid connection payload.");
122
+ }
123
+
124
+ const providerFamily = getAttachmentProviderForConnection(connection);
125
+ if (!providerFamily) {
126
+ return badRequest("This connection does not support chat attachments.");
127
+ }
128
+
129
+ const files = formData.getAll("files").filter((entry): entry is File => entry instanceof File);
130
+ if (files.length === 0) {
131
+ return badRequest("No files were uploaded.");
132
+ }
133
+ if (files.length > MAX_ATTACHMENTS_PER_MESSAGE) {
134
+ return badRequest(`You can upload up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`);
135
+ }
136
+
137
+ const attachments: ChatAttachment[] = [];
138
+
139
+ if (providerFamily === "anthropic") {
140
+ if (!connection.apiKey) {
141
+ return badRequest("Missing Anthropic API key.");
142
+ }
143
+ const client = new Anthropic({
144
+ apiKey: connection.apiKey,
145
+ });
146
+
147
+ for (const file of files) {
148
+ const validation = validateAttachmentCandidate({
149
+ name: file.name,
150
+ mimeType: file.type,
151
+ sizeBytes: file.size,
152
+ });
153
+ if (!validation.ok) {
154
+ return badRequest(validation.error);
155
+ }
156
+
157
+ const uploadable = new File([await file.arrayBuffer()], validation.name, {
158
+ type: validation.mimeType,
159
+ });
160
+ const uploaded = await client.beta.files.upload({
161
+ file: uploadable,
162
+ betas: ["files-api-2025-04-14"],
163
+ });
164
+
165
+ attachments.push({
166
+ provider: "anthropic",
167
+ providerFileId: uploaded.id,
168
+ kind: validation.kind,
169
+ name: validation.name,
170
+ mimeType: validation.mimeType,
171
+ sizeBytes: validation.sizeBytes,
172
+ });
173
+ }
174
+ } else {
175
+ if (connection.kind === "openai_compatible" && !connection.baseUrl) {
176
+ return badRequest("OpenAI-compatible connection is missing base URL.");
177
+ }
178
+ if (connection.kind === "builtin" && connection.provider === "openai" && !connection.apiKey) {
179
+ return badRequest("Missing OpenAI API key.");
180
+ }
181
+
182
+ const client = new OpenAI({
183
+ apiKey: connection.apiKey ?? "no-key-required",
184
+ baseURL: connection.kind === "openai_compatible" ? connection.baseUrl : undefined,
185
+ defaultHeaders: headersArrayToRecord(connection.headers),
186
+ });
187
+
188
+ for (const file of files) {
189
+ const validation = validateAttachmentCandidate({
190
+ name: file.name,
191
+ mimeType: file.type,
192
+ sizeBytes: file.size,
193
+ });
194
+ if (!validation.ok) {
195
+ return badRequest(validation.error);
196
+ }
197
+
198
+ const uploadable = await OpenAI.toFile(
199
+ await file.arrayBuffer(),
200
+ validation.name,
201
+ { type: validation.mimeType },
202
+ );
203
+ const uploaded = await client.files.create({
204
+ file: uploadable,
205
+ purpose: "user_data",
206
+ });
207
+
208
+ attachments.push({
209
+ provider: "openai",
210
+ providerFileId: uploaded.id,
211
+ kind: validation.kind,
212
+ name: validation.name,
213
+ mimeType: validation.mimeType,
214
+ sizeBytes: validation.sizeBytes,
215
+ });
216
+ }
217
+ }
218
+
219
+ return Response.json({ attachments });
220
+ }