11import type { Meta , StoryObj } from "@storybook/react-vite" ;
22import { type ComponentProps , useState } from "react" ;
33import { expect , fn , spyOn , userEvent , waitFor , within } from "storybook/test" ;
4+ import { reactRouterParameters } from "storybook-addon-remix-react-router" ;
45import { API } from "#/api/api" ;
56import type * as TypesGen from "#/api/typesGenerated" ;
67import {
@@ -48,18 +49,39 @@ const createProviderConfig = (
4849 updated_at : overrides . updated_at ?? now ,
4950} ) ;
5051
52+ type ModelProviderAttachmentOverrides = Partial <
53+ Omit < TypesGen . ChatModelProviderAttachment , "provider_config_id" >
54+ > ;
55+
5156const createModelProviderAttachment = (
5257 providerConfigId : string ,
58+ overrides : ModelProviderAttachmentOverrides = { } ,
5359) : TypesGen . ChatModelProviderAttachment => ( {
54- id : `attachment-${ providerConfigId } ` ,
60+ id : overrides . id ?? `attachment-${ providerConfigId } ` ,
5561 provider_config_id : providerConfigId ,
56- provider : "openai" ,
57- priority : 0 ,
58- display_name : providerConfigId ,
59- enabled : true ,
60- has_api_key : false ,
62+ provider : overrides . provider ?? "openai" ,
63+ priority : overrides . priority ?? 0 ,
64+ display_name : overrides . display_name ?? providerConfigId ,
65+ enabled : overrides . enabled ?? true ,
66+ has_api_key : overrides . has_api_key ?? false ,
6167} ) ;
6268
69+ const createModelProviderAttachments = (
70+ providerConfigs : readonly TypesGen . ChatProviderConfig [ ] ,
71+ ) : TypesGen . ChatModelProviderAttachment [ ] =>
72+ providerConfigs . map ( ( providerConfig , priority ) =>
73+ createModelProviderAttachment ( providerConfig . id , {
74+ provider : providerConfig . provider ,
75+ priority,
76+ display_name :
77+ providerConfig . display_name ||
78+ providerConfig . base_url ||
79+ providerConfig . id ,
80+ enabled : providerConfig . enabled ,
81+ has_api_key : providerConfig . has_api_key ,
82+ } ) ,
83+ ) ;
84+
6385const createModelConfig = (
6486 overrides : Partial < TypesGen . ChatModelConfig > &
6587 Pick < TypesGen . ChatModelConfig , "id" | "provider" | "model" > ,
@@ -78,6 +100,88 @@ const createModelConfig = (
78100 updated_at : overrides . updated_at ?? now ,
79101} ) ;
80102
103+ const createModelProviderAttachmentsFromIDs = (
104+ providerConfigIds : readonly string [ ] ,
105+ providerConfigs : readonly TypesGen . ChatProviderConfig [ ] ,
106+ fallbackProvider : string ,
107+ ) : TypesGen . ChatModelProviderAttachment [ ] =>
108+ providerConfigIds . map ( ( providerConfigId , priority ) => {
109+ const providerConfig = providerConfigs . find (
110+ ( config ) => config . id === providerConfigId ,
111+ ) ;
112+
113+ return createModelProviderAttachment ( providerConfigId , {
114+ provider : providerConfig ?. provider ?? fallbackProvider ,
115+ priority,
116+ display_name :
117+ providerConfig ?. display_name ||
118+ providerConfig ?. base_url ||
119+ providerConfigId ,
120+ enabled : providerConfig ?. enabled ?? true ,
121+ has_api_key : providerConfig ?. has_api_key ?? false ,
122+ } ) ;
123+ } ) ;
124+
125+ const multiAttachmentPrimaryProviderConfig = createProviderConfig ( {
126+ id : "3f4f2e43-9c0b-4b22-a0cf-4f4f20f994f1" ,
127+ provider : "openai" ,
128+ display_name : "OpenAI Primary" ,
129+ has_api_key : true ,
130+ has_effective_api_key : true ,
131+ base_url : "https://api.openai.com/v1" ,
132+ } ) ;
133+
134+ const multiAttachmentSandboxProviderConfig = createProviderConfig ( {
135+ id : "55ed7e92-fad1-4d2e-9bba-78d27af5c949" ,
136+ provider : "openai" ,
137+ display_name : "OpenAI Sandbox" ,
138+ has_api_key : false ,
139+ has_effective_api_key : false ,
140+ allow_user_api_key : true ,
141+ base_url : "https://sandbox.openai.example.com/v1" ,
142+ } ) ;
143+
144+ const multiAttachmentArchiveProviderConfig = createProviderConfig ( {
145+ id : "8e12d651-7430-4eb9-b2d6-d80a6107b7fb" ,
146+ provider : "openai" ,
147+ display_name : "OpenAI Archive" ,
148+ enabled : false ,
149+ has_api_key : false ,
150+ has_effective_api_key : false ,
151+ allow_user_api_key : true ,
152+ base_url : "https://archive.openai.example.com/v1" ,
153+ } ) ;
154+
155+ const multiAttachmentRecoveryProviderConfig = createProviderConfig ( {
156+ id : "9d0b27c9-4637-4e8f-8dcb-2bd1fb4e6c1f" ,
157+ provider : "openai" ,
158+ display_name : "OpenAI Disaster Recovery" ,
159+ has_api_key : true ,
160+ has_effective_api_key : true ,
161+ base_url : "https://dr.openai.example.com/v1" ,
162+ } ) ;
163+
164+ const multiAttachmentProviderConfigs = [
165+ multiAttachmentPrimaryProviderConfig ,
166+ multiAttachmentSandboxProviderConfig ,
167+ multiAttachmentArchiveProviderConfig ,
168+ multiAttachmentRecoveryProviderConfig ,
169+ ] ;
170+
171+ const multiAttachmentModelConfig = createModelConfig ( {
172+ id : "6d447897-7a60-4209-9dfa-46f726726d46" ,
173+ provider : "openai" ,
174+ provider_configs : createModelProviderAttachments ( [
175+ multiAttachmentSandboxProviderConfig ,
176+ multiAttachmentPrimaryProviderConfig ,
177+ multiAttachmentArchiveProviderConfig ,
178+ ] ) ,
179+ model : "gpt-4.1" ,
180+ display_name : "GPT-4.1 Router" ,
181+ enabled : true ,
182+ context_limit : 128000 ,
183+ } ) ;
184+
81185type ChatModelAdminPanelStoryProps = ComponentProps < typeof ChatModelAdminPanel > ;
82186
83187/**
@@ -170,8 +274,10 @@ const setupChatSpies = (state: {
170274 const created = createModelConfig ( {
171275 id : `model-${ state . modelConfigs . length + 1 } ` ,
172276 provider : req . provider ,
173- provider_configs : ( req . provider_config_ids ?? [ ] ) . map (
174- createModelProviderAttachment ,
277+ provider_configs : createModelProviderAttachmentsFromIDs (
278+ req . provider_config_ids ?? [ ] ,
279+ state . providerConfigs ,
280+ req . provider ,
175281 ) ,
176282 model : req . model ,
177283 display_name : req . display_name || req . model ,
@@ -218,7 +324,11 @@ const setupChatSpies = (state: {
218324 ...req ,
219325 provider_configs :
220326 req . provider_config_ids !== undefined
221- ? req . provider_config_ids . map ( createModelProviderAttachment )
327+ ? createModelProviderAttachmentsFromIDs (
328+ req . provider_config_ids ,
329+ state . providerConfigs ,
330+ current . provider ,
331+ )
222332 : current . provider_configs ,
223333 id : current . id ,
224334 provider : current . provider ,
@@ -460,6 +570,57 @@ export const MultiConfigModelBinding: Story = {
460570 } ,
461571} ;
462572
573+ export const MultiProviderAttachments : Story = {
574+ args : {
575+ section : "models" as ChatModelAdminSection ,
576+ providerConfigsData : multiAttachmentProviderConfigs ,
577+ modelConfigsData : [ multiAttachmentModelConfig ] ,
578+ modelCatalogData : { providers : [ ] } ,
579+ } ,
580+ parameters : {
581+ reactRouter : reactRouterParameters ( {
582+ location : {
583+ searchParams : { model : multiAttachmentModelConfig . id } ,
584+ } ,
585+ } ) ,
586+ } ,
587+ play : async ( { canvasElement } ) => {
588+ const body = within ( canvasElement . ownerDocument . body ) ;
589+
590+ await expect (
591+ await body . findByLabelText ( / M o d e l I d e n t i f i e r / i) ,
592+ ) . toBeInTheDocument ( ) ;
593+ await expect (
594+ await body . findByText ( "Provider Configurations" ) ,
595+ ) . toBeVisible ( ) ;
596+
597+ const attachmentNames = body
598+ . getAllByText ( / O p e n A I ( S a n d b o x | P r i m a r y | A r c h i v e ) / )
599+ . map ( ( element ) => element . textContent ) ;
600+ expect ( attachmentNames ) . toEqual ( [
601+ "OpenAI Sandbox" ,
602+ "OpenAI Primary" ,
603+ "OpenAI Archive" ,
604+ ] ) ;
605+
606+ const moveUpButtons = body . getAllByRole ( "button" , { name : "Move up" } ) ;
607+ const moveDownButtons = body . getAllByRole ( "button" , { name : "Move down" } ) ;
608+ const removeButtons = body . getAllByRole ( "button" , { name : "Remove" } ) ;
609+
610+ expect ( moveUpButtons ) . toHaveLength ( 3 ) ;
611+ expect ( moveUpButtons [ 0 ] ) . toBeDisabled ( ) ;
612+ expect ( moveDownButtons ) . toHaveLength ( 3 ) ;
613+ expect ( moveDownButtons [ 2 ] ) . toBeDisabled ( ) ;
614+ expect ( removeButtons ) . toHaveLength ( 3 ) ;
615+
616+ expect ( body . getAllByText ( "Enabled" ) ) . toHaveLength ( 2 ) ;
617+ expect ( body . getAllByText ( "Disabled" ) ) . toHaveLength ( 1 ) ;
618+ expect ( body . getAllByText ( "No API key" ) ) . toHaveLength ( 2 ) ;
619+ expect ( body . getByText ( "API key set" ) ) . toBeInTheDocument ( ) ;
620+ expect ( body . getByText ( "Add configuration..." ) ) . toBeInTheDocument ( ) ;
621+ } ,
622+ } ;
623+
463624export const CreateAndUpdateProvider : Story = {
464625 render : function CreateAndUpdateProvider ( args ) {
465626 const [ providerConfigsData , setProviderConfigsData ] = useState (
0 commit comments